Payment on Arrival (PoA) Guide
Overview
Payment on Arrival (PoA) is an alternative payment method that allows customers to make bookings without upfront payment, with payment collected when they arrive at the venue. This guide explains the PoA workflow, lifecycle management, and automated processes.
What is Payment on Arrival?
PoA enables customers to:
- Reserve time slots without immediate payment
- Receive booking confirmation with payment deadline
- Complete payment upon arrival at the venue
- Avoid upfront payment friction
Key Benefits:
- Lower Booking Friction: Customers don't need payment cards ready
- Flexible Payment Options: Pay with cash, card, or other methods on-site
- Trust Building: Reduces concerns about upfront payments
- Operational Control: Verify customer arrival before charging
PoA vs. Upfront Payment
Upfront Payment Flow
Customer Books
↓
Payment Collected Immediately
↓
Payment Authorized/Captured
↓
Booking Status: CONFIRMED
↓
Customer Arrives → Activity Starts
Payment on Arrival Flow
Customer Books
↓
No Payment Collected (PoA Selected)
↓
Booking Status: TENTATIVE
↓
poa_due_at Set (e.g., 24h before arrival)
↓
Reminder Sent Before Due Date
↓
Customer Arrives → Confirm Arrival
↓
Collect Payment On-Site
↓
Booking Status: CONFIRMED → PAID
Key Differences:
| Aspect | Upfront Payment | Payment on Arrival |
|---|---|---|
| Initial Status | CONFIRMED | TENTATIVE |
| Payment Timing | During booking | Upon arrival |
| Payment Due Date | Immediate | Before/at arrival (poa_due_at) |
| Cancellation Risk | Low (payment secured) | Higher (no payment held) |
| Customer Friction | Higher (requires payment method) | Lower (book first, pay later) |
| No-Show Risk | Lower | Higher (requires monitoring) |
PoA Booking Lifecycle
1. Booking Creation with PoA
When a customer creates a booking with PoA selected:
// Customer creates booking with PoA
const booking = await bookingService.createBooking({
siteId: 's_demo',
productId: 'p_laser_tag',
variantId: 'v_standard',
startsAt: '2025-11-20T14:00:00Z',
partySize: 8,
paymentMethod: 'payment_on_arrival', // PoA selected
customerInfo: {
name: 'John Doe',
email: 'john@example.com',
phone: '+1234567890',
},
});
// Booking created with:
// - status: TENTATIVE (awaiting arrival & payment)
// - poaDueAt: 2025-11-20T12:00:00Z (2h before startsAt)
// - paymentStatus: PENDING
Booking Record:
{
"id": "booking_abc123",
"status": "TENTATIVE",
"paymentStatus": "PENDING",
"startsAt": "2025-11-20T14:00:00.000Z",
"endsAt": "2025-11-20T16:00:00.000Z",
"poaDueAt": "2025-11-20T12:00:00.000Z",
"grandTotal": 10000,
"currency": "EUR"
}
2. Reminder Notification
24 hours before poaDueAt, the system automatically sends a reminder:
// PoaReminderService checks for upcoming due dates
const bookingsNeedingReminder = await db.booking.findMany({
where: {
status: 'TENTATIVE',
poaDueAt: {
gte: now,
lte: addHours(now, 24),
},
// Check if reminder not already sent via audit log
},
});
// Send reminder for each booking
for (const booking of bookingsNeedingReminder) {
await poaReminderService.sendPoaReminder(booking);
}
Reminder Email Content:
Subject: Payment Reminder for Your Upcoming Booking
Dear John Doe,
Your booking for Laser Tag on Wednesday, November 20, 2025 at 2:00 PM is confirmed!
Payment Details:
- Amount Due: €100.00
- Payment Deadline: Wednesday, November 20, 2025 at 12:00 PM
- Payment Method: Pay at venue upon arrival
Please bring cash or card payment to complete your booking.
Booking Details:
- Activity: Laser Tag
- Date & Time: Wednesday, November 20, 2025 at 2:00 PM
- Party Size: 8 participants
- Booking Reference: booking_abc123
If you have any questions, please contact our customer service team.
Reminder Tracking:
The system tracks that a reminder was sent to prevent duplicate notifications:
// Create audit event for reminder sent
await db.booking_audit_log.create({
data: {
tenantId: booking.tenantId,
bookingId: booking.id,
eventType: 'poa_reminder_sent',
userId: 'system',
isAdminAction: false,
newState: {
reminder_sent_at: new Date().toISOString(),
booking_status: booking.status,
poa_due_at: booking.poaDueAt?.toISOString(),
},
changeReason: 'Automated PoA reminder sent',
},
});
3. Customer Arrival & Confirmation
When the customer arrives at the venue:
// Admin confirms customer arrival
await bookingService.confirmArrival({
bookingId: 'booking_abc123',
arrivedAt: new Date(),
confirmedBy: 'admin_user_123',
});
// Booking updated:
// - status: CHECKED_IN
// - arrivedAt: 2025-11-20T13:45:00Z
Arrival Confirmation Flow:
Customer Arrives at Venue
↓
Admin Checks Booking Reference
↓
Verify Customer Identity
↓
Mark as "Arrived" in System
↓
Booking Status → CHECKED_IN
↓
Collect Payment
↓
Record Payment
↓
Booking Payment Status → PAID
↓
Activity Begins
4. Payment Collection
After arrival confirmation, payment is collected on-site:
// Record on-site payment
await paymentService.recordPoaPayment({
bookingId: 'booking_abc123',
paymentMethod: 'cash', // or 'card', 'mobile', etc.
amountPaid: 10000, // in cents
currency: 'EUR',
receivedBy: 'admin_user_123',
receiptNumber: 'RCP-20251120-001',
});
// Booking updated:
// - paymentStatus: PAID
// - status: CONFIRMED (if not already CHECKED_IN)
For a deposit booking whose product uses balanceDueMode: 'on_arrival' (see Catalog → Deposits & Partial Payments), arrival collection is deposit-aware: it charges the outstanding balance (grand total − amount already paid), not the full total, and derives the resulting status via the shared deposit-payment-state helper — part_paid becomes paid only once the balance is settled. This prevents the deposit portion from being charged twice.
5. Auto-Cancellation (Overdue Bookings)
If payment is not received by poaDueAt + grace period (24h default):
// PoaAutoCancelService runs periodically
const overdueBookings = await db.booking.findMany({
where: {
status: 'TENTATIVE',
poaDueAt: {
lte: subtractHours(now, 24), // 24h grace period passed
},
},
});
// Auto-cancel each overdue booking
for (const booking of overdueBookings) {
await db.booking.update({
where: { id: booking.id },
data: {
status: 'CANCELED',
canceledAt: new Date(),
cancelReason: 'Payment not received by due date',
},
});
// Send cancellation notifications
await poaAutoCancelService.handleAutoCancellation(booking.id);
// Release capacity
await capacityService.releaseBookingCapacity(booking);
}
Auto-Cancellation Notifications:
Subject: Booking Cancelled - Payment Not Received
Dear John Doe,
Unfortunately, your booking has been automatically cancelled because payment was
not received by the deadline.
Original Booking Details:
- Activity: Laser Tag
- Date & Time: Wednesday, November 20, 2025 at 2:00 PM
- Party Size: 8 participants
- Amount: €100.00
- Payment Deadline: Wednesday, November 20, 2025 at 12:00 PM
You can make a new booking on our website or by contacting our customer service team.
We apologize for any inconvenience.
Database Schema
Booking Table (PoA Fields)
model Booking {
id String @id
status String // TENTATIVE, CHECKED_IN, CONFIRMED, CANCELED, etc.
paymentStatus String // PENDING, PAID, REFUNDED
// PoA-specific fields
poaDueAt DateTime? @db.Timestamptz(6) @map("poa_due_at")
arrivedAt DateTime? @db.Timestamptz(6)
// Standard booking fields
startsAt DateTime
endsAt DateTime
grandTotal Decimal
currency String
// ... other fields
}
PoA Field Descriptions:
- poaDueAt: Deadline for payment arrival. Calculated based on tenant policy (e.g., 2h before
startsAt) - arrivedAt: Timestamp when customer physically arrived at venue
- status:
TENTATIVE- PoA booking created, awaiting paymentCHECKED_IN- Customer arrived, awaiting payment collectionCONFIRMED- Payment received (if arrival not tracked)CANCELED- Auto-cancelled due to non-payment
- paymentStatus:
PENDING- No payment received yetPAID- Payment collected
Booking Audit Log (PoA Events)
model booking_audit_log {
id String @id
tenantId String
bookingId String
eventType String // 'poa_reminder_sent', 'auto_cancel_poa', etc.
userId String // 'system' for automated events
isAdminAction Boolean
previousState Json?
newState Json?
changeReason String?
systemNotes String?
createdAt DateTime @default(now())
}
PoA Event Types:
poa_reminder_sent- Reminder notification sent to customerauto_cancel_poa- Booking auto-cancelled due to overdue paymentauto_cancellation_notified- Cancellation notification sentpoa_arrival_confirmed- Customer arrival confirmed by adminpoa_payment_received- Payment collected on-site
Service Implementations
PoaJobSchedulerService
Orchestrates all PoA background jobs with configurable intervals.
@Injectable()
export class PoaJobSchedulerService implements OnModuleInit {
private readonly logger = new Logger(PoaJobSchedulerService.name);
private reminderInterval?: NodeJS.Timeout;
private autoCancelInterval?: NodeJS.Timeout;
// Default configuration
private readonly defaultConfig = {
reminderOffsetHours: 24, // Send reminder 24h before due
reminderCheckIntervalMs: 5 * 60 * 1000, // Check every 5 minutes
graceWindowHours: 24, // Cancel 24h after due
autoCancelCheckIntervalMs: 15 * 60 * 1000, // Check every 15 minutes
enabled: true,
};
constructor(
private readonly reminderService: PoaReminderService,
private readonly autoCancelService: PoaAutoCancelService,
) {}
async onModuleInit() {
await this.start();
}
async start(): Promise<void> {
this.logger.log('Starting PoA job scheduler');
// Start reminder job
this.reminderInterval = setInterval(async () => {
try {
await this.processReminders();
} catch (error) {
this.logger.error('Error processing PoA reminders', error);
}
}, this.defaultConfig.reminderCheckIntervalMs);
// Start auto-cancel job
this.autoCancelInterval = setInterval(async () => {
try {
await this.processAutoCancellations();
} catch (error) {
this.logger.error('Error processing PoA auto-cancellations', error);
}
}, this.defaultConfig.autoCancelCheckIntervalMs);
}
private async processReminders(): Promise<void> {
const now = new Date();
const targetTime = addHours(now, this.defaultConfig.reminderOffsetHours);
const bookings = await db.booking.findMany({
where: {
status: 'TENTATIVE',
poaDueAt: {
gte: now,
lte: targetTime,
},
},
include: {
site: { select: { id: true, name: true, tenantId: true } },
product: { select: { id: true, name: true } },
},
});
for (const booking of bookings) {
// Check if reminder already sent
const alreadySent = await this.reminderService.hasReminderBeenSent(
booking.id,
);
if (!alreadySent) {
await this.reminderService.sendPoaReminder(booking);
}
}
}
private async processAutoCancellations(): Promise<void> {
const cutoffTime = subtractHours(
new Date(),
this.defaultConfig.graceWindowHours,
);
// Use database function to atomically expire bookings
const result = await db.$queryRaw`
SELECT * FROM expire_overdue_poa_bookings(${this.defaultConfig.graceWindowHours});
`;
// Send notifications for each cancelled booking
for (const expiredBooking of result) {
await this.autoCancelService.handleAutoCancellationSafe(
expiredBooking.expired_booking_id,
);
}
}
}
PoaReminderService
Sends payment reminder notifications to customers.
@Injectable()
export class PoaReminderService {
private readonly logger = new Logger(PoaReminderService.name);
constructor(private readonly notificationService: NotificationService) {}
async sendPoaReminder(booking: BookingForReminder): Promise<void> {
this.logger.log(`Sending PoA reminder for booking ${booking.id}`);
if (!booking.poaDueAt) {
this.logger.warn(`Booking ${booking.id} has no poa_due_at, skipping`);
return;
}
const prisma = db();
// Calculate hours until payment is due
const hoursUntilDue = Math.round(
(booking.poaDueAt.getTime() - Date.now()) / (1000 * 60 * 60),
);
// Prepare notification template data
const templateData = {
booking_id: booking.id,
booking_reference: booking.id,
site_name: booking.site.name,
product_name: booking.product.name,
party_size: booking.partySize,
booking_start_time: booking.startsAt.toISOString(),
poa_due_at: booking.poaDueAt.toISOString(),
hours_until_due: hoursUntilDue,
formatted_due_date: this.formatDueDate(booking.poaDueAt),
formatted_booking_time: this.formatBookingTime(booking.startsAt),
total_amount: (Number(booking.grandTotal) / 100).toFixed(2),
currency: booking.currency || 'EUR',
payment_instructions: this.getPaymentInstructions(booking.site.tenantId),
contact_info: this.getContactInfo(booking.site.tenantId),
};
// Send notification
await this.notificationService.sendNotification(prisma, {
booking_id: booking.id,
template_key: 'poa_payment_reminder',
recipient_type: 'customer',
recipient_email: booking.customerEmail,
recipient_phone: booking.customerPhone,
recipient_name: booking.customerName,
template_data: templateData,
delivery_methods: ['email', 'sms'],
priority: 'high',
send_immediately: true,
source: 'poa_reminder_job',
correlation_id: `poa_reminder_${booking.id}_${Date.now()}`,
});
// Mark reminder as sent
await this.markReminderSent(booking.id);
}
private async markReminderSent(bookingId: string): Promise<void> {
const prisma = db();
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
});
if (!booking) return;
const site = await prisma.site.findUnique({
where: { id: booking.siteId },
select: { tenantId: true },
});
if (!site) return;
// Create audit event
await withTenant(site.tenantId, async (tx) => {
await tx.booking_audit_log.create({
data: {
tenantId: site.tenantId,
bookingId: bookingId,
eventType: 'poa_reminder_sent',
userId: 'system',
isAdminAction: false,
newState: {
reminder_sent_at: new Date().toISOString(),
booking_status: booking.status,
poa_due_at: booking.poaDueAt?.toISOString(),
},
changeReason: 'Automated PoA reminder sent',
systemNotes: 'PoA Reminder Job',
ipAddress: 'system',
userAgent: 'PoA Reminder Job',
},
});
});
}
async hasReminderBeenSent(bookingId: string): Promise<boolean> {
const prisma = db();
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
select: { siteId: true },
});
if (!booking) return false;
const site = await prisma.site.findUnique({
where: { id: booking.siteId },
select: { tenantId: true },
});
if (!site) return false;
const reminderEvent = await withTenant(site.tenantId, async (tx) => {
return tx.booking_audit_log.findFirst({
where: {
bookingId: bookingId,
eventType: 'poa_reminder_sent',
},
});
});
return reminderEvent !== null;
}
private formatDueDate(dueDate: Date): string {
return dueDate.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
private formatBookingTime(startTime: Date): string {
return startTime.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
private getPaymentInstructions(tenantId: string): string {
// TODO: Make configurable per tenant/site
return 'Please bring cash or card payment to complete your booking upon arrival.';
}
private getContactInfo(tenantId: string): string {
// TODO: Make configurable per tenant/site
return 'For questions about your booking, please contact our customer service team.';
}
}
PoaAutoCancelService
Handles automatic cancellation of overdue PoA bookings.
@Injectable()
export class PoaAutoCancelService {
private readonly logger = new Logger(PoaAutoCancelService.name);
constructor(private readonly notificationService: NotificationService) {}
async handleAutoCancellationSafe(bookingId: string): Promise<void> {
this.logger.log(`Handling safe auto-cancellation for booking ${bookingId}`);
const prisma = db();
// Check if notification already sent (idempotency)
const existingNotificationEvent = await prisma.booking_audit_log.findFirst({
where: {
bookingId: bookingId,
eventType: 'auto_cancellation_notified',
},
});
if (existingNotificationEvent) {
this.logger.log(
`Auto-cancellation notification already sent for ${bookingId}, skipping`,
);
return;
}
// Process cancellation
await this.handleAutoCancellation(bookingId);
// Mark as notified
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
select: { siteId: true },
});
if (booking) {
const site = await prisma.site.findUnique({
where: { id: booking.siteId },
select: { tenantId: true },
});
if (site) {
await prisma.booking_audit_log.create({
data: {
tenantId: site.tenantId,
bookingId: bookingId,
eventType: 'auto_cancellation_notified',
userId: 'system',
isAdminAction: false,
newState: {
notification_sent_at: new Date().toISOString(),
processed_by: 'auto_cancel_safe',
},
changeReason: 'Automated PoA cancellation notification',
systemNotes: 'PoA Auto-Cancel Service',
ipAddress: 'system',
userAgent: 'poa-auto-cancel-service',
},
});
}
}
}
async handleAutoCancellation(bookingId: string): Promise<void> {
this.logger.log(`Handling auto-cancellation for booking ${bookingId}`);
const prisma = db();
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
});
if (!booking) {
this.logger.warn(`Booking ${bookingId} not found`);
return;
}
const [site, product] = await Promise.all([
prisma.site.findUnique({
where: { id: booking.siteId },
select: { id: true, name: true, tenantId: true },
}),
prisma.product.findUnique({
where: { id: booking.productId },
select: { id: true, name: true },
}),
]);
if (!site || !product) {
this.logger.warn(`Site or product not found for booking ${bookingId}`);
return;
}
// Send notifications
await this.sendAutoCancellationNotifications(booking, site, product);
// Create outbox events
await this.createAutoCancellationEvents(booking, site);
// Log audit event
await this.logAutoCancellationAudit(booking, site, product);
}
private async sendAutoCancellationNotifications(
booking: any,
site: any,
product: any,
): Promise<void> {
const prisma = db();
const templateData = {
booking_id: booking.id,
booking_reference: booking.id,
site_name: site.name,
product_name: product.name,
party_size: booking.partySize,
booking_start_time: booking.startsAt.toISOString(),
poa_due_at: booking.poaDueAt?.toISOString(),
cancellation_reason: 'Payment not received by due date',
formatted_booking_time: this.formatBookingTime(booking.startsAt),
formatted_due_date: booking.poaDueAt
? this.formatDueDate(booking.poaDueAt)
: 'N/A',
total_amount: (Number(booking.grandTotal) / 100).toFixed(2),
currency: booking.currency || 'EUR',
rebooking_instructions: this.getRebookingInstructions(site.tenantId),
contact_info: this.getContactInfo(site.tenantId),
};
// Send customer notification
try {
await this.notificationService.sendNotification(prisma, {
booking_id: booking.id,
template_key: 'poa_auto_cancellation_customer',
recipient_type: 'customer',
recipient_email: booking.customerEmail,
recipient_phone: booking.customerPhone,
recipient_name: booking.customerName,
template_data: templateData,
delivery_methods: ['email'],
priority: 'high',
send_immediately: true,
source: 'poa_auto_cancel_job',
correlation_id: `poa_auto_cancel_customer_${booking.id}_${Date.now()}`,
});
} catch (error) {
this.logger.error(
`Failed to send customer cancellation notification for ${booking.id}`,
error,
);
}
// Send admin notification
try {
await this.notificationService.sendNotification(prisma, {
booking_id: booking.id,
template_key: 'poa_auto_cancellation_admin',
recipient_type: 'admin',
recipient_email: 'admin@example.com', // TODO: Get from site config
template_data: {
...templateData,
admin_dashboard_url: this.getAdminDashboardUrl(
site.tenantId,
booking.id,
),
booking_history_url: this.getBookingHistoryUrl(
site.tenantId,
booking.id,
),
},
delivery_methods: ['email'],
priority: 'normal',
send_immediately: true,
source: 'poa_auto_cancel_job',
correlation_id: `poa_auto_cancel_admin_${booking.id}_${Date.now()}`,
});
} catch (error) {
this.logger.error(
`Failed to send admin cancellation notification for ${booking.id}`,
error,
);
}
}
private async createAutoCancellationEvents(
booking: any,
site: any,
): Promise<void> {
// Create outbox events for downstream systems
// TODO: Implement outbox event creation
this.logger.log(
`Created auto-cancellation outbox events for booking ${booking.id}`,
);
}
private async logAutoCancellationAudit(
booking: any,
site: any,
product: any,
): Promise<void> {
const prisma = db();
await prisma.booking_audit_log.create({
data: {
tenantId: site.tenantId,
bookingId: booking.id,
eventType: 'auto_cancel_poa',
userId: 'system',
isAdminAction: false,
newState: {
reason: 'poa_payment_overdue',
poa_due_at: booking.poaDueAt?.toISOString(),
auto_cancelled_at: new Date().toISOString(),
original_status: 'TENTATIVE',
party_size: booking.partySize,
booking_start_time: booking.startsAt.toISOString(),
site_id: site.id,
product_id: product.id,
},
changeReason: 'PoA payment overdue - automatic cancellation',
systemNotes: 'PoA Auto-Cancel Job',
ipAddress: 'system',
userAgent: 'PoA Auto-Cancel Job',
},
});
}
private formatDueDate(dueDate: Date): string {
return dueDate.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
private formatBookingTime(startTime: Date): string {
return startTime.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
private getRebookingInstructions(tenantId: string): string {
return 'You can make a new booking on our website or by contacting our customer service team.';
}
private getContactInfo(tenantId: string): string {
return 'For questions about your cancelled booking, please contact our customer service team.';
}
private getAdminDashboardUrl(tenantId: string, bookingId: string): string {
return `https://admin.example.com/tenants/${tenantId}/bookings/${bookingId}`;
}
private getBookingHistoryUrl(tenantId: string, bookingId: string): string {
return `https://admin.example.com/tenants/${tenantId}/bookings/${bookingId}/history`;
}
}
Admin Operations
Viewing PoA Bookings
Admins can filter bookings by payment method:
// Get all PoA bookings (TENTATIVE status)
GET /api/v1/admin/bookings?status=TENTATIVE
// Filter by payment due soon
GET /api/v1/admin/bookings?poa_due_within_hours=24
// Response
{
"bookings": [
{
"id": "booking_abc123",
"status": "TENTATIVE",
"paymentStatus": "PENDING",
"customerName": "John Doe",
"startsAt": "2025-11-20T14:00:00.000Z",
"poaDueAt": "2025-11-20T12:00:00.000Z",
"grandTotal": 10000,
"currency": "EUR",
"hoursUntilDue": 23
}
]
}
Confirming Arrival
When a customer arrives:
POST /api/v1/admin/bookings/:bookingId/confirm-arrival
{
"arrivedAt": "2025-11-20T13:45:00.000Z",
"confirmedBy": "admin_user_123"
}
// Response
{
"success": true,
"booking": {
"id": "booking_abc123",
"status": "CHECKED_IN",
"arrivedAt": "2025-11-20T13:45:00.000Z"
}
}
Recording Payment
After arrival, record payment:
POST /api/v1/admin/bookings/:bookingId/record-payment
{
"paymentMethod": "cash",
"amountPaid": 10000,
"currency": "EUR",
"receivedBy": "admin_user_123",
"receiptNumber": "RCP-20251120-001"
}
// Response
{
"success": true,
"booking": {
"id": "booking_abc123",
"paymentStatus": "PAID",
"status": "CONFIRMED"
},
"payment": {
"id": "payment_xyz789",
"method": "cash",
"amount": 10000,
"receiptNumber": "RCP-20251120-001"
}
}
Manual Reminder Send
Admins can manually send reminders:
POST /api/v1/admin/bookings/:bookingId/send-poa-reminder
// Response
{
"success": true,
"message": "Reminder sent successfully",
"sentTo": {
"email": "john@example.com",
"phone": "+1234567890"
}
}
Manual Cancellation
Admins can manually cancel PoA bookings:
POST /api/v1/admin/bookings/:bookingId/cancel
{
"reason": "Customer requested cancellation",
"canceledBy": "admin_user_123"
}
// Response
{
"success": true,
"booking": {
"id": "booking_abc123",
"status": "CANCELED",
"canceledAt": "2025-11-19T10:30:00.000Z",
"cancelReason": "Customer requested cancellation"
}
}
Configuration
Tenant-Specific PoA Settings
// Tenant configuration for PoA
interface PoaTenantConfig {
enabled: boolean;
// Payment deadline calculation
poaDueOffsetHours: number; // Hours before startsAt (default: 2)
// Reminder settings
reminderEnabled: boolean;
reminderOffsetHours: number; // Hours before poaDueAt (default: 24)
// Auto-cancellation settings
autoCancelEnabled: boolean;
graceWindowHours: number; // Hours after poaDueAt (default: 24)
// Notification settings
notificationMethods: ('email' | 'sms' | 'push')[];
customerServiceEmail: string;
customerServicePhone: string;
// Payment instructions
paymentInstructions: string;
acceptedPaymentMethods: ('cash' | 'card' | 'mobile')[];
}
// Example configuration
const tenantPoaConfig: PoaTenantConfig = {
enabled: true,
poaDueOffsetHours: 2,
reminderEnabled: true,
reminderOffsetHours: 24,
autoCancelEnabled: true,
graceWindowHours: 24,
notificationMethods: ['email', 'sms'],
customerServiceEmail: 'support@example.com',
customerServicePhone: '+1-800-123-4567',
paymentInstructions: 'Please bring cash or card payment upon arrival.',
acceptedPaymentMethods: ['cash', 'card', 'mobile'],
};
Best Practices
1. Clear Communication
Do:
- ✅ Clearly communicate payment deadline in booking confirmation
- ✅ Send reminder notifications well in advance
- ✅ Provide multiple payment method options
- ✅ Include contact information for questions
- ✅ Explain auto-cancellation policy upfront
Don't:
- ❌ Assume customers remember payment deadline
- ❌ Send only one notification
- ❌ Use technical jargon in customer communications
- ❌ Cancel without grace period
2. Reminder Timing
Recommended Schedule:
- Initial Booking: Confirmation email with payment deadline
- 24h Before Due: First reminder email/SMS
- 6h Before Due (optional): Second reminder email/SMS
- At Due Time: Final reminder (if still unpaid)
3. Grace Period Management
Recommended Grace Periods:
- Same-day bookings: 0-2 hours grace
- Next-day bookings: 4-8 hours grace
- Future bookings: 24 hours grace
Considerations:
- Popular time slots: Shorter grace period
- Off-peak times: Longer grace period
- VIP customers: Extended grace period
- Repeat customers: Flexible policies
4. Idempotency
Always implement idempotency for PoA operations:
// Check if reminder already sent
const alreadySent = await hasReminderBeenSent(bookingId);
if (alreadySent) {
return; // Skip duplicate reminder
}
// Check if cancellation notification already sent
const alreadyNotified = await hasAutoCancelNotificationSent(bookingId);
if (alreadyNotified) {
return; // Skip duplicate notification
}
5. Audit Trail
Maintain comprehensive audit logs:
// Log all PoA events
await auditLog.create({
eventType: 'poa_reminder_sent',
bookingId: booking.id,
userId: 'system',
metadata: {
reminder_sent_at: new Date(),
hours_until_due: 24,
delivery_methods: ['email', 'sms'],
},
});
await auditLog.create({
eventType: 'poa_payment_received',
bookingId: booking.id,
userId: adminUserId,
metadata: {
payment_method: 'cash',
amount_paid: 10000,
received_by: adminUserId,
receipt_number: 'RCP-001',
},
});
6. Capacity Management
Release capacity when PoA bookings are cancelled:
// Auto-cancel handler
await db.$transaction(async (tx) => {
// Cancel booking
await tx.booking.update({
where: { id: bookingId },
data: {
status: 'CANCELED',
canceledAt: new Date(),
},
});
// Release capacity for all slots
const bookingSlots = await tx.bookingSlot.findMany({
where: { bookingId },
});
for (const slot of bookingSlots) {
await capacityService.releaseSlotCapacity(tx, slot);
}
});
7. Error Handling
Handle failures gracefully:
// Reminder sending with error handling
try {
await sendPoaReminder(booking);
} catch (error) {
logger.error(`Failed to send reminder for ${booking.id}`, error);
// Create failed notification record
await db.failed_notification.create({
data: {
bookingId: booking.id,
notificationType: 'poa_reminder',
error: error.message,
retryCount: 0,
nextRetryAt: addMinutes(new Date(), 15),
},
});
// Don't throw - continue processing other bookings
}
Monitoring & Observability
Key Metrics
// PoA-specific metrics
interface PoaMetrics {
// Booking metrics
total_poa_bookings: number;
poa_bookings_pending: number;
poa_bookings_paid: number;
poa_bookings_cancelled: number;
// Conversion metrics
poa_conversion_rate: number; // % of PoA bookings that get paid
poa_no_show_rate: number; // % of PoA bookings that get cancelled
// Timing metrics
avg_time_to_payment: number; // Average time from booking to payment
avg_time_to_cancellation: number; // Average time from due to cancel
// Notification metrics
reminders_sent: number;
reminders_failed: number;
cancellation_notifications_sent: number;
// Revenue metrics
poa_revenue: number;
poa_revenue_lost_to_cancellation: number;
}
Dashboard Queries
-- PoA bookings awaiting payment
SELECT
COUNT(*) as pending_poa_bookings,
SUM(grand_total) as pending_revenue
FROM bookings
WHERE status = 'TENTATIVE'
AND poa_due_at IS NOT NULL;
-- PoA conversion rate (last 30 days)
SELECT
COUNT(CASE WHEN payment_status = 'PAID' THEN 1 END)::float /
COUNT(*)::float * 100 as conversion_rate
FROM bookings
WHERE poa_due_at IS NOT NULL
AND created_at >= NOW() - INTERVAL '30 days';
-- PoA bookings due soon
SELECT
id,
customer_name,
starts_at,
poa_due_at,
grand_total,
EXTRACT(EPOCH FROM (poa_due_at - NOW())) / 3600 as hours_until_due
FROM bookings
WHERE status = 'TENTATIVE'
AND poa_due_at BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
ORDER BY poa_due_at ASC;
-- PoA reminder delivery status
SELECT
COUNT(*) FILTER (WHERE event_type = 'poa_reminder_sent') as reminders_sent,
COUNT(*) FILTER (WHERE event_type = 'auto_cancel_poa') as auto_cancellations
FROM booking_audit_log
WHERE created_at >= NOW() - INTERVAL '24 hours';
Alerting
Set up alerts for:
// Alert: High PoA cancellation rate
if (poa_no_show_rate > 30) {
alert('HIGH_POA_CANCELLATION_RATE', {
current_rate: poa_no_show_rate,
threshold: 30,
action: 'Review PoA policies and reminder timing',
});
}
// Alert: Reminder delivery failures
if (reminder_failure_rate > 5) {
alert('POA_REMINDER_DELIVERY_FAILURE', {
failed_count: failed_reminders,
action: 'Check notification service health',
});
}
// Alert: Large PoA booking pending
if (pending_poa_booking_total > 10000) {
alert('LARGE_POA_BOOKING_PENDING', {
booking_id: booking.id,
amount: booking.grandTotal,
due_at: booking.poaDueAt,
action: 'Monitor closely and consider manual follow-up',
});
}
Troubleshooting
Issue: Reminders Not Sending
Symptoms:
- Customers not receiving payment reminders
- No
poa_reminder_sentaudit events
Diagnosis:
// Check if job scheduler is running
const schedulerStatus = await poaJobScheduler.getStatus();
console.log('Scheduler running:', schedulerStatus.isRunning);
// Check for bookings needing reminders
const bookingsNeedingReminder = await db.booking.findMany({
where: {
status: 'TENTATIVE',
poaDueAt: {
gte: new Date(),
lte: addHours(new Date(), 24),
},
},
});
console.log('Bookings needing reminder:', bookingsNeedingReminder.length);
// Check notification service health
const notificationHealth = await notificationService.healthCheck();
console.log('Notification service:', notificationHealth);
Solutions:
- Verify PoA job scheduler is started
- Check notification service configuration
- Review email/SMS provider credentials
- Check for failed notification records
Issue: Auto-Cancellation Not Working
Symptoms:
- Overdue PoA bookings remain in TENTATIVE status
- Capacity not released after deadline
Diagnosis:
// Check for overdue bookings
const overdueBookings = await db.booking.findMany({
where: {
status: 'TENTATIVE',
poaDueAt: {
lte: subtractHours(new Date(), 24),
},
},
});
console.log('Overdue bookings:', overdueBookings);
// Check auto-cancel job status
const autoCancelStatus = await poaJobScheduler.getAutoCancelStatus();
console.log('Auto-cancel job:', autoCancelStatus);
// Manually trigger cancellation for testing
await poaAutoCancelService.manuallyExpireBooking(bookingId, 24);
Solutions:
- Verify auto-cancel job is running
- Check database function
expire_overdue_poa_bookingsexists - Review grace period configuration
- Manually trigger cancellation if needed
Issue: Duplicate Reminders
Symptoms:
- Customers receiving multiple reminder emails
- Duplicate
poa_reminder_sentaudit events
Diagnosis:
// Check for duplicate audit events
const reminderEvents = await db.booking_audit_log.findMany({
where: {
bookingId: bookingId,
eventType: 'poa_reminder_sent',
},
orderBy: { createdAt: 'asc' },
});
console.log('Reminder events:', reminderEvents);
// Check if idempotency check is working
const hasReminder = await reminderService.hasReminderBeenSent(bookingId);
console.log('Has reminder been sent:', hasReminder);
Solutions:
- Ensure
hasReminderBeenSentcheck is implemented - Review audit log creation for race conditions
- Add database unique constraint if needed
- Implement distributed lock for reminder sending
Issue: Payment Recorded But Booking Still TENTATIVE
Symptoms:
- Admin records payment but booking status doesn't update
- Payment record exists but booking shows PENDING
Diagnosis:
// Check booking and payment records
const booking = await db.booking.findUnique({
where: { id: bookingId },
include: { payments: true },
});
console.log('Booking:', booking);
console.log('Payments:', booking.payments);
// Check for failed status transition
const auditLog = await db.booking_audit_log.findMany({
where: {
bookingId: bookingId,
eventType: { in: ['payment_recorded', 'status_changed'] },
},
orderBy: { createdAt: 'desc' },
});
console.log('Status transition audit:', auditLog);
Solutions:
- Ensure payment recording updates booking status
- Use transaction to atomically update payment and booking
- Check for validation errors preventing status update
- Review booking status transition rules
Summary
Payment on Arrival (PoA) provides a flexible alternative to upfront payment, enabling customers to book activities and pay upon arrival. The platform automates the entire PoA lifecycle:
Key Features:
- Automated Reminders: Send payment reminders before deadline
- Auto-Cancellation: Cancel overdue bookings after grace period
- Audit Trail: Track all PoA events for compliance
- Admin Tools: Confirm arrival and record payments
- Capacity Management: Release capacity when bookings cancelled
Core Services:
- PoaJobSchedulerService: Orchestrates background jobs
- PoaReminderService: Sends payment reminders
- PoaAutoCancelService: Handles automatic cancellations
Best Practices:
- Clear communication of payment deadlines
- Multiple reminder notifications
- Appropriate grace periods
- Comprehensive audit logging
- Idempotent operations
- Graceful error handling
Monitoring:
- Track PoA conversion rates
- Monitor reminder delivery
- Alert on high cancellation rates
- Dashboard for pending PoA bookings
PoA reduces booking friction while maintaining operational control through automated workflows and comprehensive monitoring.
Related Documentation
- Outbox Pattern Guide - Reliable event delivery for PoA notifications
- Saga Orchestration Guide - Distributed transactions in booking flow
- Payments API Reference - Payment processing endpoints
- Bookings API Reference - Booking management endpoints and lifecycle
- Admin Calendar Guide - Managing PoA bookings in calendar view
- Database Schema Reference - PoA database schema details