Booking financial state is ledger-derived. Treat amount_paid, amount_refunded, and outstanding_balance on inventory_schedules as cache columns, not as the source of truth. New walk-up and order-page money changes must post journal entries first, then let the cache sync derive the booking summary from the ledger.
Source-of-truth modules
| Responsibility | Source |
|---|---|
| Walk-up and order financial state changes | packages/RentalTide-Server/src/services/booking/financialTransaction.ts |
| Balance-payment endpoint/webhook posting | packages/RentalTide-Server/src/services/booking/balancePaymentPosting.ts |
| Canonical booking fee math | packages/RentalTide-Server/src/services/payments/bookingFees.ts |
| Platform dues accrual and recovery | packages/RentalTide-Server/src/services/platformFeeService.ts |
| Ledger-derived booking cache | packages/RentalTide-Server/src/routes/accountingRouter/booking-cache-sync |
| Parent booking DAL guard | packages/RentalTide-Server/src/db/dal/bookings.ts |
Golden path
Use bookingFinancialTransaction for walk-up bookings and order-page financial mutations:
The helper runs inside one PostgreSQL transaction. It loads the booking, applies location GL-code overrides, builds the accounting strategy payload, writes journal entries, records tender/payment rows when applicable, syncs the booking cache, and returns the derived financial state. If any step fails, the ledger and cache roll back together.
Do not update amountPaid, amountRefunded, outstandingBalance, or PaymentInfo.RemainingBalance directly for walk-up/order money movement. The parent bookings DAL throws on non-zero direct writes to those fields for walk-up bookings unless the privileged cache-sync token is present.
Event contract
| Event type | Use for | Posting behavior |
|---|---|---|
rental_obligation_set | Starting a walk-up rental with money owed later | Clears prior booking entries, posts the unpaid obligation, then syncs cache. |
walkup_complete | Completing an in-engine walk-up payment | Clears prior entries, optionally appends a price version for the actual sale, posts payment/revenue or deferred entries, then syncs cache. |
payment_received | Collecting money from the order page or a walk-up allocation | Posts a collect-remaining event and caps clearing legs to the real open booking obligation. |
refund_issued | Booking/order refunds | Looks up original entries when needed, posts proportional refund entries, then syncs cache. |
rental_obligation_clear | Cancel/reschedule paths that should remove an open obligation | Clears prior entries and syncs cache without layering another obligation. |
gas_charge_set | Fuel charges on a booking | Clears only prior gas entries (payment_method='gas_charge'), re-posts the fuel obligation or recognized fuel revenue, then syncs cache. |
walkup_settle_via_pos | POS tab settles one or more walk-up rentals | Posts settlement entries without re-pricing the rental; POS owns the sale. |
price_adjustment | Priced booking changes after creation | Appends an immutable price_versions row and posts only the ledger delta when the booking already has a ledger position. |
Some legacy flows are intentionally outside this contract while they are being migrated: online booking creation, public reschedule, and self-service kiosk paths still have direct-write behavior. Do not add new direct-write paths.
Balance payment idempotency
Balance payments are split out because the browser endpoint and Stripe webhook can both observe the same PaymentIntent.
postBalancePayment(rentalId, paymentIntent, { source })handles one booking.postGroupBalancePayment(orderId, paymentIntent, { source })handles a merged order/payment link with several booking slices.- Both paths use a PostgreSQL advisory lock keyed by the PaymentIntent id and re-check for an existing POS transaction inside the lock. The second caller returns
alreadyPosted: true. - Group payments allocate each booking's share and split the single Stripe application fee across slices. Do not call the internal slice function directly; it does not own the PaymentIntent-level idempotency gate.
Callers currently include:
routes/publicRouter.tsendpoint confirmations for balance payments.routes/hooks/hooksRouter.tsStripepayment_intent.succeededwebhook.
Fee model
Use computeBookingFees, not inline fee math.
Constraints:
- Percentage fees are based on the booking grand total and prorated by
paymentAmount / bookingTotal. - The flat interchange base is charged in full on each card transaction.
- Platform fee is owed on every booking. If it cannot be collected from a card charge,
platformFeeServiceaccrues it to the location's outstanding dues. - Overdue-dues recovery is capped at 10% of the booking total when callers pass
bookingTotalCents; older callers that omit it fall back to the payment amount cap. - Gift-card purchases are interchange-only; booking platform fee rules should not be copied into gift-card purchase code.
Troubleshooting ledger/cache drift
- Read journal entries for the
booking_idfirst. The cache should be treated as a symptom, not the cause. - Use
BookingLedgerService.getBookingFinancialState(rentalId)to compare the ledger-derived state withinventory_schedules.amount_paid,amount_refunded, andoutstanding_balance. - If journal entries are correct but the cache is stale, run the sanctioned cache sync path. Do not hand-write the three cache columns.
- If a payment might have been seen by both the endpoint and webhook, check the POS transaction by
paymentIntentIdbefore posting anything else. - For gas edits, verify prior gas entries were removed by
payment_method='gas_charge'instead of deleting the booking's base entries. - For price edits, verify the
price_versionsrow and the journal transaction id agree. A version-only edit against a booking with no base ledger position is expected; a delta entry without a base position is not.
Safe repair pattern
Repair scripts should default to dry-run and follow the same order as production code:
- Identify the ledger defect and the exact booking population.
- Post or reverse journal entries through the accounting services or
bookingFinancialTransactionwhere possible. - Sync the booking cache from the ledger.
- Verify balanced journal entries and expected paid/refunded/outstanding values.
- Commit only durable scripts/tests/docs; remove temporary instrumentation.
For JSONB reads in raw SQL, remember that the PostgreSQL client returns JSONB as strings in this repo. Parse before inspecting nested values.

