0029. Append-only reservation folios and manual settlement
Questi contenuti non sono ancora disponibili nella tua lingua.
- Status: accepted for this implementation brief; owner review required (revised after independent review R1, and for split folios, see below)
- Date: 2026-10-27; split folios, per-room settlement and bulk re-rating 2026-10-31
Observable acceptance criteria
Section titled “Observable acceptance criteria”A receptionist opens a reservation’s Folio section, sees persisted room charges at check-in, adds a catalog extra with quantity and service date, records a deposit or manual payment, and checks out after settling the displayed balance, or with a credit the guest has left on the account. In a multi-room reservation each room can leave on its own day; only the last one has to settle. Reload retains all movements. Accounting can refund an unreversed payment up to its remaining amount and reverse a movement with a reason. Retries with the same posting key never duplicate money; changed intent with the same key is refused. Cross-workspace reads/writes and housekeeping financial reads are denied. Invalid values and failed checkout write nothing. The reservations list and Today’s departures show balance due. Staff can print a clearly non-fiscal statement in English or Italian. A viewer reads but cannot post. The explicit brief substitutes for a separate feature issue.
Financial decisions
Section titled “Financial decisions”One guest folio per reservation in v1; a separate folio identity permits future split accounts. Split folios arrived on 2026-10-31: see Split folios below. Positive signed movements increase debt; payments decrease it; refunds increase it; a reversal copies the opposite amount and opposite tax lines of its original. Nothing is edited or deleted. Every posting stores an immutable normalized intent, posting key, source, actor, instant, service date, description and currency-aware money. All totals use exact integer arithmetic and must fit the safe-integer range. Property writer gates serialize lifecycle and financial writes in one transaction; unique constraints independently protect posting keys and one reversal per original. Audit and domain events commit with postings. Runtime grants and triggers enforce append-only storage. Reasons and references stay on tenant rows, outside audit payloads, except the open-balance approval (see the check-out gate).
Migration 0009 opens accounts for existing reservations and imports each frozen night of currently in-house rooms, with system audit/events. It invents no historical payments or balances for already departed reservations. Operators record any prior deposits explicitly. All frozen nights post at check-in. Before check-in there are no room charges. An in-house departure/price correction reverses changed or unserved room postings and posts replacement frozen nights atomically; unchanged nightly lines and explicit waivers remain; arrival/type/assignment changes remain refused. Early checkout releases unserved nights, retaining at least the first night, and reverses those charges before testing settlement. Undo reconstitutes charges using new posting keys. ADR 0032 adds frozen cancellation/no-show penalties; cancellation releases room charges and retains deposits for explicit settlement through refunds. No silent rewriting of an already posted amount occurs.
Read: admin, front_desk, accounting, revenue_manager, viewer; housekeeping receives no
folio amounts (the HTTP projection derives its money filter from the same FOLIO_READ
list the use cases check). Post extras/payments: admin, front_desk, accounting
(FOLIO_POST). Refund/reverse/re-rate: admin, accounting (FOLIO_CORRECT), mandatory
reason. Lifecycle remains admin/front_desk.
Check-out gate (revised by review R1)
Section titled “Check-out gate (revised by review R1)”Payments and extras belong to the reservation account; room charges carry their room. The gate looks at the account balance after releasing the leaving room’s unserved nights, and depends on who is leaving:
| Situation | Check-out | Who |
|---|---|---|
| Not the last room of the reservation (another room is still expected or in house) | Always allowed. The balance is shown for information; nothing to settle | admin, front desk |
| Last room, balance zero | Allowed | admin, front desk |
| Last room, credit (negative balance: prepayment or deposit above the bill) | Allowed with a warning. The credit stays on the account and appears in the refunds due list for accounting | admin, front desk |
| Last room, balance owed (positive) | Refused (folio.open_balance) until settled; an administrator may confirm with a reason |
admin only |
Why: the first version demanded exactly zero for the whole reservation. A guest who prepaid and left a day early, or one room of a family leaving before the others, then needed an administrator or accountant, who are often absent at night. A credit is money the hotel owes the guest, not money the guest owes: it must not hold the guest at the desk, and refunding stays an accounting decision (admin/accounting, with a reason), not a front-desk shortcut. A room that is not the last one has no settlement of its own because payments are not allocated to rooms; allocating them (per-room folios) is planned. A room that remains expected keeps the reservation from having a last room. ADR 0032 adds explicit no-show marking, which finishes that room for settlement and refunds due.
The refunds due list shows reservations that are finished (all rooms departed, no-show or
cancelled) or cancelled and whose account is a credit, with guest, departure and amount.
It appears on the Today screen for roles that read folios and in the API
(folioCreditsDue.list).
An open-balance approval exists only when it overrides something: the last room leaves
with a positive balance. A reason sent in any other case is ignored (never stored, never
audited, no permission check), so a stale client cannot create a false approval or be
refused for sending one. The approval writes folio.open_balance_confirmed with the
actor, the room, the approved amount in minor units and its currency, and the reason. This
is the one financial reason kept in an audit payload: the room’s own copy is cleared on
undo check-out, and the audit is the lasting record of who approved what. Refund and
reversal reasons stay on their ledger rows. Undo check-out clears the room’s approval.
Posting bounds and policies
Section titled “Posting bounds and policies”Payments, refunds and reversals cannot be dated in the future of the property’s business
date, nor before the year 2000. An extra’s service date lies within the stay (first
arrival to last departure, inclusive). Extras cannot be posted to a cancelled reservation
(folio.reservation_cancelled); payments, refunds and reversals can, because deposits are
settled there. A channel never cancels a reservation in which a guest has stayed (ADR 0028),
so its account keeps accepting extras as well (since split folios, once a member reopens the folio
that the last check-out closed). Posting keys beginning room: or rerate: are reserved for the system.
The idempotency intent compares amounts as minor units, so 10.5 and 10.500 are the
same retry.
Policy kept from v1, documented for operators: a same-day check-out (or one on the arrival day) still charges the first night, even though inventory frees it for resale, and a late check-in charges the missed first night. Front desk cannot waive either; an administrator or accountant reverses the night with a reason. A configurable first-night/no-show policy belongs to the policies work, not to the ledger: ADR 0032 adds no-show fees, and a configurable served-first-night rule remains planned.
Tax extension
Section titled “Tax extension”A country-neutral TaxCalculator port is awaited for each charge, inside the posting
transaction and before any write, so an adapter may read its own tables but must not wait on
slow external services. For every charge it receives property and reservation identity,
service date, currency-aware base amount, quantity, a canonical category code and a
stay context: reservation source, room and room type, adults, children, the nights of
the room’s stay and the zero-based index of the charged night (null for charges not tied to
a night). This is what per-person, per-night levies with exemptions and caps need. It
returns whether the charge was computed, tax lines explicitly included in or added to the
frozen price (stable rule identifier, label), and optionally additional charge lines
(rule, label, category, amount). The core posts each additional line as its own charge
movement (source adapter, linked by parentId to the charge that produced it, same date
and room). Reversing a charge, by hand or by the reconciler, reverses its live lines in the
same operation; a line can also be waived alone. Lines and tax lines are frozen with the
charge and reversed exactly, never recalculated on reversal.
Categories are stable codes, never operator-typed labels: the core defines
accommodation, food_beverage, service and other; a module adds its own codes with
labels (TaxCalculator.categories). The catalog accepts only these, and
folioTaxCategories.list tells the interface what to offer. Migration 0009 had not been
applied anywhere when this was decided, so it carries the code constraint directly and
there are no free-text categories to migrate.
Existing folios. Charges already posted are never silently recomputed: installing or
changing a module leaves them as they are, and the account says taxes are not computed
until they are. Re-rate is the explicit operation (folios.rerate, admin/accounting,
reason, idempotent posting key): every live room and extra charge is calculated again with
the current adapter, and a charge whose taxes or additional lines would differ is reversed
and posted again, lines included, under system keys, with one audit event. Base prices never
change, waived (reversed) charges stay waived, and an unchanged charge is not touched.
Re-rating is per reservation in v1; a bulk run over a property is planned.
Without a module the response says taxes are not computed. The composition root selects the adapter; no country tax implementation is included.
Split folios, transfers and per-room settlement (2026-10-31)
Section titled “Split folios, transfers and per-room settlement (2026-10-31)”Acceptance: a receptionist opens a company folio on a reservation, routes the room nights to it, checks the guest in (the nights land on the company folio), moves an extra or a payment between folios with a reason, records the company’s payment on its folio, closes it once settled and prints its statement with the company’s billing details; reload keeps all of it. In a multi-room booking one room settles its own folio and checks out while the others stay. Refunds due list each folio that holds money after its stay. A viewer reads but cannot open or move; another workspace sees nothing.
One ledger, several folios. The reservation keeps one append-only ledger: posting keys,
positions and correction references are reservation-wide (migration 0038 adds
reservation_id to movements and moves the uniqueness and the reference key onto it). Each
movement is booked on one of the reservation’s folios. The existing folio of every
reservation becomes its first folio: same identity (derived from the reservation, so the
account’s id and every existing movement keep their meaning), payer the booker, no room,
closed only by the last check-out. Why one ledger rather than independent ledgers per folio: an idempotent retry
must find its posting whichever folio routing chose; room-charge reconciliation, policy fees
and tax re-rates reason about a room’s nights across folios; and the existing checks (one
reversal per movement, refunds within a payment) stay one set of rules.
Folio headers (folios rows) are the one financial table that changes in place, and only
in what may change: name, payer, room, routes, open/closed and the version. A trigger refuses
any change of identity (workspace, id, property, reservation, currency, number) and any
delete, for the owner too; the worker cannot update them at all. Optimistic concurrency uses
the folio’s version (ETag/If-Match, folio.version_mismatch), under the property writer gate.
At most ten folios per reservation (Opera-like windows; enough for guest, company, agency and
rooms), numbered in creation order.
Payer. The booker (follows the reservation’s guest), another guest of the workspace
(payer_guest_id, a composite foreign key to guests) or a company with a name and free-text
billing details. A company payer may be linked to a counterparty of the organization; closing
the folio then moves its owed balance to an open item with one receivable_transfer movement, and
reopening reverses it exactly (ADR 0051). Guest merge must re-point folios.payer_guest_id
from the absorbed guest to the survivor.
Transfers are their own posting kind (coordinator rule: revenue counts an explicit list
of revenue kinds, so transfers never count). A move is a pair of transfer movements: the
out leg on the folio the line leaves, related to the row that placed it there (the item
itself, or the in leg of its previous move), and the in leg on the target folio, related to
the out leg. Each leg negates the row it relates to, so the pair nets to zero and the
reservation’s balance and charge/payment/refund totals never change. The database enforces
it independently: one transfer per related row (unique index folio_one_transfer, so a line
can never leave a folio twice), a leg must negate its row, an out leg stays on its row’s folio
and follows a charge, a payment or an in leg, an in leg enters another folio after an out
leg, the moved item (found by walking the chain back) must be neither reversed nor a payment
with refunds in force, and a deferred constraint trigger refuses at commit an out leg without
its in leg, so no transaction can drop a line from the account. An item’s current folio is the folio of
the last row of its chain. Reversals and refunds are booked on that folio, so the balance of
a folio is simply the sum of its rows and equals the sum of the live items located there
(property-tested). A transfer itself is not reversible: moving back is another transfer.
Additional tax lines move with their charge. A transfer takes folios.transfer, a reason and
a date no later than the business date; its caller key derives the legs’ system keys
(transfer: prefix, now reserved).
Routing. Routes live on the folio that collects: room (room nights), extra (one
catalog item) or tax_category; a route belongs to one folio per reservation, so giving it to
a folio removes it from the previous holder (whose version changes). Resolution for a new
posting: an explicit folio_id; then an open folio attached to the charge’s room for that
room’s nights and policy fees; then the room, extra and tax-category routes on open folios;
else the first folio. Payments go to the named folio or the first one. Replacement nights of
a reconciliation stay on the folio of the night they replace, so a price fix never moves a
night back to the guest. Closed folios are skipped by routing.
Closing (one path). A folio closes in exactly two ways, each recording one folio.closed
domain event with the folio as subject, in the same transaction: a member closes a settled
folio other than the first (closed_by: member, zero balance or folio.not_settled), or the
check-out of the reservation’s last room closes every folio still open, the first one included
(closed_by: check_out, whatever its balance). Undoing that check-out reopens exactly the
folios it closed, each with one folio.reopened event; a member may reopen any closed folio,
with the same event. (Coordinator rule, 2026-10-31: track INT publishes folio.closed as a
webhook; this keeps one mechanical closing path. Train wave 1b publishes folio.reopened@1 too,
so a receiver that acts on a closing also learns when it is undone.) A closed folio refuses new charges and transfers (folio.closed);
payments, refunds and reversals still settle and correct it, because the refunds-due workflow
returns credits after departure and a late correction must not need a reopening. System
reconciliation (an OTA price amendment) may still post on a closed folio rather than fail an
import. Closing a folio is not a fiscal act: fiscal documents remain a country-module concern.
Per-room settlement. A folio may be attached to one room of the reservation. Check-out of
a room that is not the last one looks at the summed balance of its attached folios
(scope: room), with the same rules as the last room (credit allowed, owed balance needs
settlement or the audited folios.approve_open_balance approval). The last room still settles
the whole reservation (scope: reservation). A room without its own folio behaves as before.
Credits due are listed per folio: a credit folio of a finished or cancelled reservation, or the
credit folio of a room that has left.
Channel-cancelled rooms. The folio context now computes, itself, which rooms owe nothing
(roomsCancelled): a cancelled room, or a room the channel cancelled that nobody stayed in
(ADR 0028). The reservations context’s folioSnapshot delegates to it. Before, folio use cases
read the raw reservation and missed channel cancellations (found by the independent tests: a
folio could be attached to such a room, and an extra could be posted when every room was
channel-cancelled).
Permissions. folios.split (open, rename, payer, room, routes, close, reopen) and
folios.transfer, both built-in for administrators, front desk and accounting, like posting:
splitting a bill at the desk is routine and audited, while refunds, reversals and open-balance
approval keep their stricter grants.
Bulk re-rating (2026-10-31)
Section titled “Bulk re-rating (2026-10-31)”Acceptance: a revenue manager selects stays by night range (and rooms), previews the difference per stay at the current rates and applies it in one audited operation; in-house folios follow.
The operation belongs to the reservations context (rerateStays, rerates.apply), because it
changes frozen nightly prices; the folio follows through the existing reconciler, so no new
ledger rule exists for it. Decisions:
- Which nights. Only selected nights from the first night still to come are re-rated: the later of the business date and the property’s local date, so a lagging night audit never re-prices a night already slept. Served nights keep their frozen price.
- Which rooms. Direct sales with a rate plan only. A room is skipped with a reason when it is
closed (departed, no-show, cancelled or channel-cancelled), channel-managed (OTA price), has no
plan, carries a manual price (
priceOverridden: changing an agreed price stays an explicit room edit withreservations.override_price), has no selected future night, or its plan has no positive price for a selected night. Sold policy terms (deposit amounts, penalties) are not re-frozen: a re-rate corrects prices, not the contract’s conditions. - Frozen-price and If-Match rules. A bulk body cannot carry one If-Match per reservation, so
each selection carries the
versionits preview showed and, required to apply, the previewedexpected_totalandexpected_fingerprint. The fingerprint is a 64-bit FNV-1a digest of what the apply writes: each selected room’s selected nights at their resulting prices, or its skip reason, in a canonical order (rooms by id, nights by date), so the order a repository answers in never changes it. It is change detection, not a security token (the member who previews applies), and it catches rates that moved without changing a stay’s total (independent review of PR #290). Both are checked under the property’s write lock before anything is written; any difference answers 412rerate.stale, listing the stale reservations inreservation_ids, and writes nothing.validate_onlyis a query parameter, as on every other operation. - All or nothing. Everything runs in one property write transaction; a refusal while saving a
later reservation rolls back the ones already saved (a private error thrown out of the unit of
work, answered as the problem outside it). Each changed reservation gets its version, its
reservation.reratedaudit entry and event; onereservation.bulk_reratedentry records the counts and the total difference. No reason text is stored in audit payloads. - Grant.
reservations.rerate, built-in for administrators and revenue managers (a pricing decision, likerates.edit).
Packages (2026-10-31)
Section titled “Packages (2026-10-31)”Acceptance: a rate plan that includes services, for example breakfast, sells one price; on the folio its served nights show the accommodation and each included service as separate lines with their own tax categories, the service is never charged twice, and the revenue report splits room revenue from package components. Extras outside the package are charged as before.
- What is frozen. A plan’s package names catalog extras with a quantity per night, per guest
per night or per stay (first night only). Booking freezes, on every night, the included
services at the extra’s catalog price then: extra, name, tax category, quantity and amount
(
Night.package). The night’s price includes them; later catalog changes never touch a sold night. A night whose package would be worth its whole price or more is not sellable (package_exceeds_price): the room keeps a positive part. An overridden price keeps the quoted package only when it stays above it. - How it is posted. The room-charge reconciler posts the accommodation, which is the price
less the package, and one
packagecharge per included service, with the extra’s tax category and quantity and its own posting key. Each charge is reconciled on its own identity (night, source and extra): a change that moves value between the room and its package reverses and reposts only the charges whose value changed, and a human reversal of an included service stays a waiver until that service’s value changes, as for room nights. Package charges route with their room’s nights (the room’s own folio, then a room route), because the price the payer accepted includes them. Tax modules receive each service under its own category. - Sold basis. Each frozen component keeps the rule it was sold under
(
per_night,per_person_night,per_stay) and who set its night’s price (rate_planorchannel, migration 0055). When a stay is re-dated at its kept prices (a resize, or a quote that keeps sold nights), its once-per-stay services are normalized from those frozen components alone, never from the plan as it is now: exactly one inclusion, on the new first night, at the sold value, whatever later edits removed or re-based in the plan. - Storage. The frozen components are rows of
reservation_night_packages, deleted with their night; they keep the extra’s identifier without a foreign key, as folio movements do, because they are a record of the sale, not a link to the live catalog. - Never charged twice. Posting a catalog extra that a room’s package includes is refused with
folio.included_in_package, unless the request saysbeyond_package(a second breakfast). - Reports. Revenue by category groups by movement source, so package components are their own category beside room revenue; transfers stay excluded.
- Channels. Channex’s ARI has no package field: a plan’s price already includes its package,
and the matrix in
docs/developer/occupancy-distribution.mdrecords what the channels show. OTA bookings on a mapped plan keep the channel’s nightly price, the authoritative total, and carve the plan’s package out of it at import (components with sourcechannel, review of PR #298, B5); a booking whose plan is not mapped while its room type sells a package, or whose package the night cannot hold, keeps its whole price as sold and is listed for review on the reservation, never refused and never counted out of inventory (booking import).
Planned
Section titled “Planned”A configurable served-first-night rule (distinct from ADR 0032 no-show fees), local partial room cancellation, a bulk tax recalculation across a property, provider adapters with installer-owned accounts, invoice/fiscal documents, cash rounding adapters and country-specific tax rules are separate future work.
Integration decisions
Section titled “Integration decisions”The current user instruction fixes the base at 1ed268f, overriding the older common brief.
Prefixes are fol (folio), mov (movement), and ext (catalog extra). The v1 guest
folio derives its underlying UUID from the reservation, making a pre-posting read stable;
future additional folios will use independent injected identifiers. Database ordering uses
a per-folio position, never random UUID order within a millisecond. Room batches use one
SQL insert and one audit/event for the reconciliation operation, retaining actor and time
on each immutable movement. This preserves the existing maximum-stay SQL budget.
The tax port lives in the financial context because it freezes charge snapshots; country adapters implement it without core importing a country package. Its per-charge computed flag distinguishes an uncovered property/category from a known zero amount. The financial context consumes its own minimal reservation projection rather than importing lifecycle use cases; dependency checks enforce the resulting acyclic graph.
Housekeeping HTTP projections omit monetary totals, balances and nightly prices from reservation detail, list/history and Today/Planning data. Open-balance approval reasons are financial data and are omitted too. The read-only checkout quote is available to all financial readers; confirming checkout remains restricted to lifecycle roles. SDK money fields are optional to reflect this permission boundary; financial roles still receive the exact values. Automatic room reconciliations follow authorized lifecycle operations; the admin/accounting restriction applies to manual reversals and refunds. A receptionist can correct an unchanged historical arrival in an in-house stay while editing its departure/price.
Booking import and dependency integration
Section titled “Booking import and dependency integration”The existing OTA intake supports price amendments during an in-house stay. Intake now uses the same frozen-charge reconciler before writing the reservation. The new reservation, reversals/replacement charges, immutable tax snapshots, audit/event and revision receipt commit together; a failed audit rolls them all back before acknowledgement. Metadata-only revisions retain the existing charge snapshots and checkout approval reason. Omitting or cancelling an in-house room is quarantined as a local-stay conflict. A neutral per-room cancellation flag lets OTA cancellations reverse the obligation of a room nobody stayed in while preserving the other rooms. A cancellation does not reverse or re-price a stayed room’s obligation: an OTA-cancelled departed room keeps its nights at their frozen prices and their charges, whatever amounts the cancellation revision carries, and later price changes for that room are ignored (ADR 0028). Explicit price amendments of a room that is not cancelled, departed or not, keep their append-only reconciliation. OTA-collected payments are still not posted on the folio: an OTA-collected booking opens an expected payout owed by the OTA, outside the folio (ADR 0051).
The worker receives insert-only grants for folio identities and movements to apply those room-charge facts; both runtime roles remain unable to update, delete or truncate them. A custom TaxCalculator is passed through HTTP import and authenticated cron/worker composition, so amendments use the same adapter as local lifecycle writes. External price amendments can change a balance after departure; they retain the full posting history.
The real rates/import dependency commits are integrated locally with their authorship preserved, so migration 0009 and the combined financial workflow can be tested without placeholder migrations. No upstream pull request is merged by this task. The coordinator should merge those dependency PRs before this PR; main is fetched and merged again before delivery. Temporary diagnostic SQL copies are excluded from independent commits.
With both real prerequisite files present, file loading again requires a dense migration sequence. The dependency branch’s temporary file-gap exception is removed; no placeholder migration or relaxed financial gate is shipped.
Housekeeping’s financial restriction also applies to the newly integrated rate calendar, quotes and plan derivation amounts; these routes and navigation entries exclude that role. Inventory-only availability, planning and housekeeping operations remain available.
Rate-plan extensions retain the previously frozen nights and price only new nights at current rates. An explicit manual price correction (including an explicit plan override) can reprice existing nights; the folio records reversals and replacement charges. The rates review fixes were replayed with their original author/sign-off and their source branch ancestry recorded after resolving overlap with folio changes. API revision 1.8.0 includes the combined team, rates, import, reports and folio contracts.
Independent review R1
Section titled “Independent review R1”The first implementation was reviewed independently (docs/architecture/reviews/folio-r1.md).
Its two high findings changed this ADR: credits no longer block check-out, and multi-room
reservations settle only when the last room leaves. The medium findings produced the
approval audit rule, the enriched tax port with canonical categories and re-rate, the net
statement and ledger views, and the added tests. How each finding was handled, including the
ones deliberately not changed, is in docs/architecture/reviews/folio-r1-response.md.