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
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git
venv
**/__pycache__
**/node_modules
frontend/dist
db.sqlite3
.env
*.log
67 changes: 67 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copy to .env and fill in. Never commit .env.
DJANGO_SECRET_KEY=change-me-to-a-long-random-string
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1

# SQLite for local dev; use Postgres in staging/production:
# DATABASE_URL=postgres://hotel:password@localhost:5432/hotel
# Leave unset to use the local db.sqlite3

DEFAULT_FROM_EMAIL=noreply@yourhotel.com
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
TIME_ZONE=Africa/Lagos

# Production only (DJANGO_DEBUG=False)
# DJANGO_ADMIN_URL=some-unguessable-path/
# DJANGO_CSRF_TRUSTED_ORIGINS=https://yourhotel.com

# Auth
FRONTEND_URL=http://localhost:3000
# JWT_ACCESS_MINUTES=15
# JWT_REFRESH_HOURS=12
# Set to 1 when running behind nginx / a load balancer
# NUM_PROXIES=0

# Hotel policies
# HOTEL_CHECK_IN_TIME=14:00
# HOTEL_CHECK_OUT_TIME=12:00
# BOOKING_HOLD_MINUTES=15
# FREE_CANCELLATION_HOURS=48
# CANCELLATION_PENALTY_NIGHTS=1

# Encryption key(s) for guest ID numbers - REQUIRED in production. Back it up:
# encrypted data is unrecoverable without it. Rotate by prepending a new key.
# FIELD_ENCRYPTION_KEYS=
# REQUIRE_GUEST_ID_AT_CHECK_IN=True

# Hotel identity (printed on invoices)
# HOTEL_NAME=Grand Hotel
# HOTEL_ADDRESS=1 Ahmadu Bello Way, Victoria Island, Lagos
# HOTEL_PHONE=+234 ...
# HOTEL_EMAIL=reservations@yourhotel.com
# HOTEL_TAX_ID=
# HOTEL_CURRENCY=NGN
# INVOICE_PREFIX=INV
# CHECKOUT_REQUIRES_SETTLED_FOLIO=True

# Payments (test keys outside production)
# PAYMENT_GATEWAYS=PAYSTACK,FLUTTERWAVE
# PAYSTACK_SECRET_KEY=sk_test_...
# FLUTTERWAVE_SECRET_KEY=FLWSECK_TEST-...
# FLUTTERWAVE_WEBHOOK_HASH=<the secret hash set in the Flutterwave dashboard>
# PAYMENT_CALLBACK_URL=https://yourhotel.com/booking/payment-complete

# Background jobs: run `python manage.py run_worker` alongside the web server
# TASK_BACKEND=database
# Guest SMS / WhatsApp (Termii)
# SMS_ENABLED=True
# WHATSAPP_ENABLED=False
# TERMII_API_KEY=
# TERMII_SENDER_ID=YourHotel

# Channel manager (Channex). Connection, API key and mappings are set in the admin.
# CHANNEX_BASE_URL=https://app.channex.io/api/v1
# Door locks
# LOCK_SYSTEM=none # or: bridge
# LOCK_BRIDGE_URL=http://lock-server.local:8090
# LOCK_BRIDGE_SECRET=
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

on:
push:
pull_request:

jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env: {POSTGRES_DB: hotel, POSTGRES_USER: hotel, POSTGRES_PASSWORD: hotel}
ports: ["5432:5432"]
options: --health-cmd pg_isready --health-interval 5s --health-retries 10
env:
DJANGO_SECRET_KEY: ci-only-secret
DATABASE_URL: postgres://hotel:hotel@localhost:5432/hotel
FIELD_ENCRYPTION_KEYS: ${{ secrets.CI_FIELD_ENCRYPTION_KEY || 'cXdlcnR5dWlvcGFzZGZnaGprbHp4Y3Zibm0xMjM0NTY=' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.12", cache: pip}
- run: pip install -r requirements.txt
- run: python manage.py makemigrations --check --dry-run
- run: python manage.py test --parallel
- name: Production settings audit
run: python manage.py check --deploy --fail-level WARNING
env:
DJANGO_DEBUG: "False"
DJANGO_ALLOWED_HOSTS: example.com
# Throwaway value, long enough for the audit's strength check (never used outside CI)
DJANGO_SECRET_KEY: ci-only-deploy-audit-key-7f3a9c2e5b8d1f4a6c0e9b2d7a5f3c8e1b4d6a9f

frontend:
runs-on: ubuntu-latest
defaults: {run: {working-directory: frontend}}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: {node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json}
- run: npm ci --no-audit --no-fund
- run: npm run typecheck
- run: npm test
- run: npm run build
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ downloads/
eggs/
.eggs/
lib/
!frontend/src/lib/
lib64/
parts/
sdist/
Expand Down
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# --- Web app (React) ---------------------------------------------------------
FROM node:22-alpine AS frontend
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build

# --- API (Django) -------------------------------------------------------------
FROM python:3.12-slim AS api
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY requirements.txt ./
RUN pip install -r requirements.txt gunicorn==23.0.0
COPY . .
# The built web app is served by nginx; collected static files (admin) too
COPY --from=frontend /app/dist /app/frontend/dist
RUN DJANGO_SECRET_KEY=build-only FIELD_ENCRYPTION_KEYS=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= \
python manage.py collectstatic --noinput
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health/')"
CMD ["gunicorn", "hotel.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-"]
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Hotel PMS

A property management system for a full-service hotel, built for Nigerian operations: Naira-first billing with VAT, Paystack and Flutterwave payments, guest ID capture at check-in, NDPA 2023 data-protection tooling and SMS/WhatsApp via Termii.

## What it does

| 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 |
| 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 |
| Operations | Housekeeping tasks and room status, maintenance tickets with out-of-order blocking, POS for restaurant / bar / room service / laundry |
| Management | Night audit with business date, occupancy / ADR / RevPAR, revenue & VAT, cashier and ledger reports (CSV export), audit log of every sensitive action |
| Distribution | Channel manager (Channex) two-way sync, door-lock adapter |
| Guests | Public booking site, booking lookup and self-cancellation, email + SMS/WhatsApp notifications |
| Security & compliance | Role-based capabilities, short-lived JWT + httpOnly refresh cookie, TOTP two-factor sign-in, throttling, encrypted PII, NDPA export / erasure / retention |

## Architecture

- **API:** Django 6 + Django REST Framework (`/api/...`), PostgreSQL in production.
- **Web app:** React + TypeScript (Vite) in `frontend/`: the public website (`/`), the guest booking engine (`/book`) and the staff portal (`/login`, then `/dashboard`).
- **Background jobs:** Django's task framework with a database-backed queue (`jobs` app); run `python manage.py run_worker`.
- One Django app per domain: `booking`, `billing`, `payments`, `rates`, `rooms`, `housekeeping`, `maintenance`, `pos`, `nightaudit`, `notifications`, `distribution`, `locks`, `privacy`, `audit`, `authentication`, `jobs`. Business rules live in each app's `services.py` / `lifecycle.py`, not in views.

## Local development

```bash
python -m venv venv && venv\Scripts\activate # Windows (source venv/bin/activate elsewhere)
pip install -r requirements.txt
copy .env.example .env # then set DJANGO_SECRET_KEY etc.
python manage.py migrate
python manage.py seed_demo # demo hotel with two weeks of history (DEBUG only)
python manage.py runserver

cd frontend && npm install && npm run dev # http://localhost:3000 (proxies /api to :8000)
```

Demo staff accounts are printed by `seed_demo` (password `DemoPass#2026`). The public site is at http://localhost:3000/ and the staff portal at http://localhost:3000/login.

Tests: `python manage.py test` (backend), `npm test` and `npm run typecheck` (frontend). CI runs both on every push (`.github/workflows/ci.yml`).

## Documentation

- [docs/SYSTEM_OVERVIEW.md](docs/SYSTEM_OVERVIEW.md): what the system does and how it's built (features, architecture, data model, key flows, roles, API, jobs, integrations, configuration). Start here if you're new.
- [docs/USER_STORIES.md](docs/USER_STORIES.md): what each kind of user needs to do, with acceptance criteria.

## Production

See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). Security controls are summarised in [docs/SECURITY.md](docs/SECURITY.md) and personal-data handling in [docs/PRIVACY.md](docs/PRIVACY.md).

API reference (staff with Django admin access): `/api/docs/`.
Empty file added audit/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions audit/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.contrib import admin

from .models import AuditLog


@admin.register(AuditLog)
class AuditLogAdmin(admin.ModelAdmin):
list_display = ['created_at', 'actor_email', 'action', 'target_repr', 'ip_address']
list_filter = ['action']
search_fields = ['actor_email', 'target_repr', 'target_id', 'ip_address']
date_hierarchy = 'created_at'

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False
6 changes: 6 additions & 0 deletions audit/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class AuditConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'audit'
37 changes: 37 additions & 0 deletions audit/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 6.0 on 2026-10-13 22:04

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actor_email', models.CharField(blank=True, max_length=255)),
('action', models.CharField(choices=[('auth.login_succeeded', 'Login succeeded'), ('auth.login_failed', 'Login failed'), ('auth.logout', 'Logout'), ('auth.password_changed', 'Password changed'), ('auth.password_reset_requested', 'Password reset requested'), ('auth.password_reset_completed', 'Password reset completed'), ('staff.invited', 'Staff invited'), ('staff.updated', 'Staff updated'), ('staff.deactivated', 'Staff deactivated'), ('staff.reactivated', 'Staff reactivated'), ('rooms.room_type_created', 'Room type created'), ('rooms.room_created', 'Room created'), ('bookings.created', 'Booking created')], max_length=64)),
('target_type', models.CharField(blank=True, max_length=100)),
('target_id', models.CharField(blank=True, max_length=64)),
('target_repr', models.CharField(blank=True, max_length=255)),
('changes', models.JSONField(blank=True, default=dict)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_events', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-id'],
'indexes': [models.Index(fields=['action', 'created_at'], name='audit_audit_action_766c6d_idx'), models.Index(fields=['actor', 'created_at'], name='audit_audit_actor_i_ec0f72_idx'), models.Index(fields=['target_type', 'target_id'], name='audit_audit_target__2d648a_idx')],
},
),
]
18 changes: 18 additions & 0 deletions audit/migrations/0002_alter_auditlog_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-10-13 22:22

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('audit', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='auditlog',
name='action',
field=models.CharField(choices=[('auth.login_succeeded', 'Login succeeded'), ('auth.login_failed', 'Login failed'), ('auth.logout', 'Logout'), ('auth.password_changed', 'Password changed'), ('auth.password_reset_requested', 'Password reset requested'), ('auth.password_reset_completed', 'Password reset completed'), ('staff.invited', 'Staff invited'), ('staff.updated', 'Staff updated'), ('staff.deactivated', 'Staff deactivated'), ('staff.reactivated', 'Staff reactivated'), ('rooms.room_type_created', 'Room type created'), ('rooms.room_created', 'Room created'), ('bookings.created', 'Booking created'), ('bookings.modified', 'Booking modified'), ('bookings.checked_in', 'Guest checked in'), ('bookings.checked_out', 'Guest checked out'), ('bookings.cancelled', 'Booking cancelled'), ('bookings.no_show', 'Booking marked no-show'), ('bookings.hold_expired', 'Booking hold expired')], max_length=64),
),
]
18 changes: 18 additions & 0 deletions audit/migrations/0003_alter_auditlog_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-10-13 22:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('audit', '0002_alter_auditlog_action'),
]

operations = [
migrations.AlterField(
model_name='auditlog',
name='action',
field=models.CharField(choices=[('auth.login_succeeded', 'Login succeeded'), ('auth.login_failed', 'Login failed'), ('auth.logout', 'Logout'), ('auth.password_changed', 'Password changed'), ('auth.password_reset_requested', 'Password reset requested'), ('auth.password_reset_completed', 'Password reset completed'), ('staff.invited', 'Staff invited'), ('staff.updated', 'Staff updated'), ('staff.deactivated', 'Staff deactivated'), ('staff.reactivated', 'Staff reactivated'), ('rooms.room_type_created', 'Room type created'), ('rooms.room_created', 'Room created'), ('bookings.created', 'Booking created'), ('bookings.modified', 'Booking modified'), ('bookings.checked_in', 'Guest checked in'), ('bookings.checked_out', 'Guest checked out'), ('bookings.cancelled', 'Booking cancelled'), ('bookings.no_show', 'Booking marked no-show'), ('bookings.hold_expired', 'Booking hold expired'), ('bookings.room_moved', 'Booking moved to another room'), ('guests.updated', 'Guest profile updated'), ('guests.id_captured', 'Guest ID captured'), ('guests.id_revealed', 'Guest ID number revealed')], max_length=64),
),
]
18 changes: 18 additions & 0 deletions audit/migrations/0004_alter_auditlog_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-10-14 05:10

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('audit', '0003_alter_auditlog_action'),
]

operations = [
migrations.AlterField(
model_name='auditlog',
name='action',
field=models.CharField(choices=[('auth.login_succeeded', 'Login succeeded'), ('auth.login_failed', 'Login failed'), ('auth.logout', 'Logout'), ('auth.password_changed', 'Password changed'), ('auth.password_reset_requested', 'Password reset requested'), ('auth.password_reset_completed', 'Password reset completed'), ('staff.invited', 'Staff invited'), ('staff.updated', 'Staff updated'), ('staff.deactivated', 'Staff deactivated'), ('staff.reactivated', 'Staff reactivated'), ('rooms.room_type_created', 'Room type created'), ('rooms.room_created', 'Room created'), ('bookings.created', 'Booking created'), ('bookings.modified', 'Booking modified'), ('bookings.checked_in', 'Guest checked in'), ('bookings.checked_out', 'Guest checked out'), ('bookings.cancelled', 'Booking cancelled'), ('bookings.no_show', 'Booking marked no-show'), ('bookings.hold_expired', 'Booking hold expired'), ('bookings.room_moved', 'Booking moved to another room'), ('guests.updated', 'Guest profile updated'), ('guests.id_captured', 'Guest ID captured'), ('guests.id_revealed', 'Guest ID number revealed'), ('billing.folio_opened', 'Folio opened'), ('billing.folio_settled', 'Folio settled'), ('billing.folio_transferred', 'Folio transferred to city ledger'), ('billing.charge_posted', 'Charge posted'), ('billing.charge_voided', 'Charge voided'), ('billing.charge_transferred', 'Charge transferred'), ('billing.payment_received', 'Payment received'), ('billing.payment_voided', 'Payment voided'), ('billing.refund_issued', 'Refund issued'), ('billing.invoice_issued', 'Invoice issued'), ('billing.account_payment_received', 'City ledger payment received'), ('billing.configured', 'Tax rule or exchange rate changed')], max_length=64),
),
]
18 changes: 18 additions & 0 deletions audit/migrations/0005_alter_auditlog_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-10-14 05:23

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('audit', '0004_alter_auditlog_action'),
]

operations = [
migrations.AlterField(
model_name='auditlog',
name='action',
field=models.CharField(choices=[('auth.login_succeeded', 'Login succeeded'), ('auth.login_failed', 'Login failed'), ('auth.logout', 'Logout'), ('auth.password_changed', 'Password changed'), ('auth.password_reset_requested', 'Password reset requested'), ('auth.password_reset_completed', 'Password reset completed'), ('staff.invited', 'Staff invited'), ('staff.updated', 'Staff updated'), ('staff.deactivated', 'Staff deactivated'), ('staff.reactivated', 'Staff reactivated'), ('rooms.room_type_created', 'Room type created'), ('rooms.room_created', 'Room created'), ('bookings.created', 'Booking created'), ('bookings.modified', 'Booking modified'), ('bookings.checked_in', 'Guest checked in'), ('bookings.checked_out', 'Guest checked out'), ('bookings.cancelled', 'Booking cancelled'), ('bookings.no_show', 'Booking marked no-show'), ('bookings.hold_expired', 'Booking hold expired'), ('bookings.room_moved', 'Booking moved to another room'), ('guests.updated', 'Guest profile updated'), ('guests.id_captured', 'Guest ID captured'), ('guests.id_revealed', 'Guest ID number revealed'), ('billing.folio_opened', 'Folio opened'), ('billing.folio_settled', 'Folio settled'), ('billing.folio_transferred', 'Folio transferred to city ledger'), ('billing.charge_posted', 'Charge posted'), ('billing.charge_voided', 'Charge voided'), ('billing.charge_transferred', 'Charge transferred'), ('billing.payment_received', 'Payment received'), ('billing.payment_voided', 'Payment voided'), ('billing.refund_issued', 'Refund issued'), ('billing.invoice_issued', 'Invoice issued'), ('billing.account_payment_received', 'City ledger payment received'), ('billing.configured', 'Tax rule or exchange rate changed'), ('bookings.confirmed', 'Booking confirmed after payment'), ('payments.succeeded', 'Online payment succeeded'), ('payments.refunded', 'Online payment refunded'), ('payments.needs_review', 'Online payment needs review'), ('payments.link_created', 'Payment link created')], max_length=64),
),
]
Loading
Loading