Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A property management system for a full-service hotel, built for Nigerian operat

| Area | Highlights |
|---|---|
| Reservations | Online and staff bookings, group/multi-room, rate plans (BAR, packages, corporate, promo codes), modifications with re-pricing, configurable cancellation & no-show policy, expiring payment holds |
| Reservations | Online and staff bookings, group/multi-room (one email per group; check in, check out or cancel the whole group, by staff or, for cancelling, the guest online), rate plans (BAR, packages, corporate, promo codes), modifications with re-pricing, configurable cancellation & no-show policy, expiring payment holds |
| Front desk | Room board, walk-ins, check-in with encrypted ID capture, room moves, key cards, check-out |
| Billing | Guest folios, nightly room posting, extras, voids & allowances with reasons, split bills, NGN + foreign-currency payments, refunds, city ledger (corporate accounts), gapless invoice numbering, PDF invoices |
| Payments | Paystack & Flutterwave checkout, signed webhooks, server-side verification, automatic refund when a late payment can't get a room |
Expand Down
80 changes: 80 additions & 0 deletions booking/groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Acting on a whole booking group at once: check everyone in, check everyone
out, or cancel the lot. Each room goes through the normal lifecycle rules on
its own, so one room that isn't ready (still being cleaned, money owed)
doesn't hold up the others; the result says what happened to every room.
The guest gets one message for the group instead of one per room.
"""
from hotel.clock import business_date
from hotel.errors import DomainError
from notifications.services import notify

from . import lifecycle
from .exceptions import InvalidTransitionError
from .models import Booking

Status = Booking.Status


def group_bookings(group):
return list(group.bookings.select_related('guest', 'room__room_type').order_by('pk'))


def _outcome(booking, error=''):
return {'confirmation_number': booking.confirmation_number,
'room_number': booking.room.room_number if booking.room_id else None,
'ok': not error, 'error': error}


def _each(bookings, action):
"""Run `action` on every booking, collecting failures instead of stopping. Returns (done, results)."""
done, results = [], []
for booking in bookings:
try:
done.append(action(booking))
except DomainError as e:
results.append(_outcome(booking, e.message))
else:
results.append(_outcome(booking))
return done, results


def check_in_group(group, *, identification=None, request=None):
"""Check in every confirmed room of the group that arrives on or before today."""
today = business_date()
arriving = [b for b in group_bookings(group)
if b.status == Status.CONFIRMED and b.check_in_date <= today < b.check_out_date]
if not arriving:
raise InvalidTransitionError("No rooms in this group are due to check in today.")
# The group shares one booker, so an ID document recorded now covers every room
if identification:
lifecycle.record_identification(arriving[0].guest, identification, request=request)
_, results = _each(arriving, lambda b: lifecycle.check_in(b, request=request))
return results


def check_out_group(group, *, request=None):
"""Check out every in-house room of the group; one thank-you email carries all the invoices."""
in_house = [b for b in group_bookings(group) if b.status == Status.CHECKED_IN]
if not in_house:
raise InvalidTransitionError("No rooms in this group are checked in.")
done, results = _each(in_house, lambda b: lifecycle.check_out(b, request=request, notify_guest=False))
if done:
notify('group_checked_out', done[0])
return results


def cancellable(group):
return [b for b in group_bookings(group) if b.status in (Status.PENDING, Status.CONFIRMED)]


def cancel_group(group, *, reason='', waive_penalty=False, request=None, by_guest=False):
"""Cancel every room still to come under the cancellation policy; one message lists them all."""
rooms = cancellable(group)
if not rooms:
raise InvalidTransitionError("Nothing in this group can be cancelled.")
done, results = _each(rooms, lambda b: lifecycle.cancel(
b, reason=reason, waive_penalty=waive_penalty, request=request, by_guest=by_guest, notify_guest=False))
if done:
notify('group_cancelled', done[0])
return results
12 changes: 8 additions & 4 deletions booking/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,12 @@ def bring_folio_up_to_date(booking, *, request=None):
return billing.folio_for(booking)


def check_out(booking, *, transfer_to_account=None, request=None):
def check_out(booking, *, transfer_to_account=None, request=None, notify_guest=True):
"""
Post any unbilled room nights, then close the folio: settle it if paid,
or bill it to `transfer_to_account` (city ledger). With
CHECKOUT_REQUIRES_SETTLED_FOLIO, check-out is refused while money is owed.
`notify_guest=False` leaves the thank-you to the caller (a group check-out sends one).
"""
with transaction.atomic():
booking = _lock(booking)
Expand Down Expand Up @@ -198,7 +199,8 @@ def check_out(booking, *, transfer_to_account=None, request=None):
mark_dirty(room, request=request, reason=f"Check-out {booking.confirmation_number}")

revoke_keys(booking, reason='Checked out', request=request)
notify('checked_out', booking)
if notify_guest:
notify('checked_out', booking)
early = business_date() < booking.check_out_date
record(AuditLog.Action.BOOKING_CHECKED_OUT, request=request, target=booking,
changes={'early_departure': early} if early else None)
Expand All @@ -225,10 +227,11 @@ def _apply_penalty(booking, penalty, waive, reason):
booking.penalty_amount = penalty


def cancel(booking, *, reason='', waive_penalty=False, request=None, by_guest=False):
def cancel(booking, *, reason='', waive_penalty=False, request=None, by_guest=False, notify_guest=True):
"""
Cancel a pending or confirmed booking, applying the cancellation policy.
Unpaid holds are released free of charge. Only staff may waive penalties.
`notify_guest=False` leaves the message to the caller (a group cancellation sends one).
"""
if by_guest and waive_penalty:
raise BookingError("Guests cannot waive cancellation penalties.")
Expand All @@ -255,7 +258,8 @@ def cancel(booking, *, reason='', waive_penalty=False, request=None, by_guest=Fa
# cancellation's transaction: a slow or failing gateway never blocks the cancellation itself)
from payments.services import refund_prepaid_remainder
refund_prepaid_remainder(booking, request=request)
notify('booking_cancelled', booking)
if notify_guest:
notify('booking_cancelled', booking)
return booking


Expand Down
65 changes: 63 additions & 2 deletions booking/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from decimal import Decimal

from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
Expand All @@ -10,7 +12,7 @@
from rooms.services import get_available_rooms_for_type

from .exceptions import BookingError
from .models import Booking, Guest
from .models import Booking, BookingGroup, Guest
from .policies import cancellation_penalty, describe_cancellation_policy, free_cancellation_deadline
from .validation import validate_stay

Expand Down Expand Up @@ -172,11 +174,13 @@ class BookingConfirmationSerializer(RatePlanInfoMixin, TaxQuoteMixin, Cancellati
room_type_name = serializers.CharField(source='room.room_type.name', read_only=True)
payments = PaymentSerializer(source='payment_transactions', many=True, read_only=True)
refunded = serializers.SerializerMethodField(help_text="Paid back to the guest from this booking's bill")
group_reference = serializers.CharField(source='group.reference', default=None, read_only=True,
help_text="Set when the room was booked as part of a group")

class Meta:
model = Booking
fields = [
'confirmation_number', 'guest', 'room_number', 'room_type_name', 'rate_plan',
'confirmation_number', 'group_reference', 'guest', 'room_number', 'room_type_name', 'rate_plan',
'check_in_date', 'check_out_date', 'number_of_adults', 'number_of_children',
'price_per_night', 'total_price', 'taxes', 'total_with_taxes', 'status', 'payments', 'refunded',
'cancellation_policy', 'free_cancellation_until', 'cancellation_fee_if_cancelled_now',
Expand Down Expand Up @@ -213,3 +217,60 @@ def validate(self, data):

class GuestCancelSerializer(GuestBookingAccessSerializer):
reason = serializers.CharField(max_length=500, required=False, allow_blank=True, default='')


class GuestGroupAccessSerializer(serializers.Serializer):
"""Guests manage a whole group with its reference (GR-...) and the booker's email."""
reference = serializers.CharField(max_length=20)
email = serializers.EmailField()

def validate(self, data):
data['group'] = (
BookingGroup.objects.select_related('booker')
.filter(reference=data['reference'].strip().upper(), booker__email__iexact=data['email'].strip())
.first()
)
return data


class GuestGroupCancelSerializer(GuestGroupAccessSerializer):
reason = serializers.CharField(max_length=500, required=False, allow_blank=True, default='')


class GuestGroupSerializer(serializers.ModelSerializer):
"""A group as its booker sees it: every room, and what the whole group costs or would cost to cancel."""
bookings = serializers.SerializerMethodField()
totals = serializers.SerializerMethodField()

class Meta:
model = BookingGroup
fields = ['reference', 'bookings', 'totals']

def _rooms(self, obj):
if not hasattr(obj, '_rooms'):
obj._rooms = list(obj.bookings.select_related('guest', 'room__room_type', 'group', 'rate_plan', 'folio')
.prefetch_related('payment_transactions').order_by('pk'))
return obj._rooms

@extend_schema_field(BookingConfirmationSerializer(many=True))
def get_bookings(self, obj):
if not hasattr(obj, '_rooms_data'):
obj._rooms_data = BookingConfirmationSerializer(self._rooms(obj), many=True).data
return obj._rooms_data

@extend_schema_field(serializers.DictField())
def get_totals(self, obj) -> dict:
rooms = self.get_bookings(obj)
live = [r for r in rooms if r['status'] != Booking.Status.CANCELLED]
fees = [r['cancellation_fee_if_cancelled_now'] for r in rooms if r['cancellation_fee_if_cancelled_now'] is not None]

def total(values):
return f"{sum((Decimal(v) for v in values), Decimal('0')):.2f}"
return {
'rooms': len(live),
'total_with_taxes': total(r['total_with_taxes'] for r in live),
# What cancelling every room still to come would cost now (None when nothing can be cancelled)
'cancellation_fee_if_cancelled_now': total(fees) if fees else None,
'cancellable_rooms': len(fees),
'refunded': total(r['refunded'] for r in rooms),
}
12 changes: 11 additions & 1 deletion booking/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ def send_booking_confirmation(booking):
notify('booking_confirmed', booking)


def send_confirmations(bookings):
"""Confirm bookings made together: one message per room, or a single one listing a group's rooms."""
grouped = [b for b in bookings if b.group_id]
for booking in bookings:
if not booking.group_id:
send_booking_confirmation(booking)
if grouped:
notify('group_confirmed', grouped[0])


def create_online_booking(validated_data):
"""
Online booking: hold a room and start a gateway checkout. The booking is
Expand Down Expand Up @@ -253,5 +263,5 @@ def create_staff_reservation(*, guest, rooms, check_in_date, check_out_date, spe
'rate_plan': rate_plan.code if rate_plan else 'BAR',
'rate_override': str(rate_override) if rate_override is not None else None,
'override_reason': override_reason or None})
send_booking_confirmation(booking)
send_confirmations(bookings)
return bookings
53 changes: 52 additions & 1 deletion booking/staff_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from django.utils import timezone

from .models import Booking, Guest, GuestIdentification
from .models import Booking, BookingGroup, Guest, GuestIdentification
from rates.models import RatePlan

from .serializers import CancellationInfoMixin, PaymentSerializer, RatePlanInfoMixin, TaxQuoteMixin
Expand Down Expand Up @@ -195,3 +195,54 @@ def validate(self, data):
if bool(data.get('existing_guest')) == bool(data.get('guest')):
raise serializers.ValidationError("Provide either guest_id (existing guest) or guest (new guest).")
return data


class GroupActionResultSerializer(serializers.Serializer):
"""What a whole-group action did to one room."""
confirmation_number = serializers.CharField()
room_number = serializers.CharField(allow_null=True)
ok = serializers.BooleanField()
error = serializers.CharField(allow_blank=True, help_text="Why this room was skipped (empty when ok).")


class BookingGroupSerializer(serializers.ModelSerializer):
"""A group with every room in it and the figures the desk needs at a glance."""
booker = GuestProfileSerializer(read_only=True)
created_by = serializers.EmailField(source='created_by.email', default=None, read_only=True)
bookings = serializers.SerializerMethodField()
totals = serializers.SerializerMethodField()

class Meta:
model = BookingGroup
fields = ['reference', 'booker', 'notes', 'created_by', 'created_at', 'bookings', 'totals']
read_only_fields = fields

def _bookings(self, obj):
if not hasattr(obj, '_rooms'):
obj._rooms = list(
obj.bookings.select_related('guest', 'room__room_type', 'group', 'created_by', 'folio', 'rate_plan')
.prefetch_related('payment_transactions').order_by('pk'))
return obj._rooms

@extend_schema_field(ReservationSerializer(many=True))
def get_bookings(self, obj):
return ReservationSerializer(self._bookings(obj), many=True).data

@extend_schema_field(serializers.DictField())
def get_totals(self, obj) -> dict:
rooms = self._bookings(obj)
live = [b for b in rooms if b.status != Booking.Status.CANCELLED]
balance = sum((b.folio.balance for b in rooms if hasattr(b, 'folio')), 0)
return {
'rooms': len(live),
'adults': sum(b.number_of_adults for b in live),
'children': sum(b.number_of_children for b in live),
'total_price': f"{sum((b.total_price for b in live), 0):.2f}",
'balance': f"{balance:.2f}",
'by_status': {s: sum(1 for b in rooms if b.status == s) for s in {b.status for b in rooms}},
}


class GroupActionResponseSerializer(serializers.Serializer):
group = BookingGroupSerializer()
results = GroupActionResultSerializer(many=True)
3 changes: 2 additions & 1 deletion booking/staff_urls.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from django.urls import path
from rest_framework.routers import SimpleRouter

from .staff_views import GuestViewSet, ReservationViewSet, RoomBoardView, WalkInView
from .staff_views import BookingGroupViewSet, GuestViewSet, ReservationViewSet, RoomBoardView, WalkInView

router = SimpleRouter()
router.register('reservations', ReservationViewSet, basename='reservation')
router.register('guests', GuestViewSet, basename='guest')
router.register('groups', BookingGroupViewSet, basename='booking-group')

urlpatterns = [
path('front-desk/walk-in/', WalkInView.as_view(), name='walk-in'),
Expand Down
Loading
Loading