Skip to content

0054. Online payments: provider-settled links, saved cards, holds and refunds

  • Status: accepted for implementation; owner review required
  • Date: 2026-11-01
  • Depends on: ADRs 0015 (country packs), 0021 (customer-owned credentials), 0029 (folios), 0032 (policies), 0040 (sealed secrets, signed deliveries), 0043 (cash drawer), 0044 (paid Channex apps)

An administrator connects the workspace’s own Stripe account in Settings → Payments with a restricted key and the webhook signing secret, sees the webhook address to register with Stripe, verifies the key and disconnects. The webhook address names the workspace, not the connection, so it is shown before connecting: Stripe gives the signing secret only once the address is registered. A sandbox workspace accepts only test keys and a production workspace only live keys. A receptionist creates a payment link from a reservation or a folio, for a prepayment, the balance, a fee, a damage deposit or a card guarantee, copies it or shows it as a QR code, and cancels it. The guest opens it, reads what it is for and continues to Stripe’s own page. Only Stripe’s signed events, or GuestCraft’s reads of Stripe, settle the outcome: the folio receives one payment (method card_online) that the cash and finance reports show, a saved card confirms the reservation’s card guarantee, and a damage deposit becomes a hold whose expiry is shown. Staff charge a saved card for what is due after confirming it, capture part of a hold by posting the damage charge, release the rest, and refund a provider payment from the refunds-due list. Settling a folio wholly in cash applies the property’s country-pack cash rounding as its own movement. Airbnb bookings refuse off-platform collection. Reload keeps all of it; another workspace sees none of it; a member without the grants cannot act.

packages/core/src/payments/provider.ts is the port: capabilities (authorize and capture, partial refund, off-session charges, currencies, methods, checkout page lifetime), the calls GuestCraft makes and the reads that settle them. Amounts cross it in ISO 4217 minor units; the adapter converts to the provider’s denomination and refuses what it cannot represent (Stripe does not list KWD; ISK and UGX use a two-decimal representation at Stripe). packages/stripe implements it on Stripe’s HTTP API, pinned to Stripe-Version: 2026-09-30.endive, without the Stripe SDK.

Each workspace connects its own account (ADR 0021); the project runs no account and none is required: without a connection the PMS works as before. Only restricted keys are accepted (rk_test_/rk_live_; a secret or publishable key is a field error), and the mode must match the workspace: sandbox ↔ test, production ↔ live. Verification is an authenticated read of the account (GET /v1/account); it proves the key and the account, not every write permission, and the documentation lists the permissions the key needs. The key and the webhook signing secret are sealed per record with ENCRYPTION_KEYS (their own purpose, guestcraft/payment-secret) and follow the re-encryption command. Disconnecting is refused while links are open, holds are not released, provider work is not settled or signed deliveries wait to be processed, because only the key and the signing secret can finish them; replacing the key with another account’s is refused for the same reason. Settings → Payments shows what is pending. Deliveries dead-lettered after their last attempt do not keep the connection; they are shown with the operator’s retry.

Provider calls that move money (customer, checkout page, charge, capture, release, refund) run only on the instance that owns the database’s background writes, like outbound webhooks: a preview deployment or a restored copy reads the same rows and moves nobody’s money.

A payment request, a provider payment, an authorization, a capture, a refund and a dispute change state only from provider facts, through one application path (processing.ts):

  1. a delivery to /api/payments/stripe/{org}/{ws} finds the workspace’s one connection only when the organization in the address is the workspace’s (the connection’s row is bound to its organization by row-level security and by the repository); any other address is refused before the signature is checked and nothing is stored. It is verified over its exact bytes with the connection’s signing secret (Stripe-Signature, 300 s tolerance on its own timestamp), refused when its mode differs from the connection’s, and stored once per event identifier (payment_events) before the 200; storing fails → 503, and Stripe retries;
  2. a job (payments.process) reads the object the event names from Stripe (the event body is not trusted beyond that reference, and events repeat and arrive in any order) and applies the object’s current state;
  3. a reconciliation job (payments.reconcile, dispatched every minute for workspaces with late work) reads what GuestCraft does not know: a call whose answer was lost (found by the markers GuestCraft put in the object’s metadata), a payment or refund still unconfirmed after two minutes, a hold whose capture_before passed, a provider page left open, and the deliveries whose processing failed. A delivery is tried twenty times; after the last attempt it is dead-lettered (payment_event.dead_lettered, audited), shown on Settings → Payments and offered again by an operator’s “Retry failed deliveries” (paymentConnection.retryDeliveries). A write the provider never received is given up fifteen minutes after it was asked (a capture returns to the hold), never re-sent: Stripe keeps idempotency keys only for a day, and a late re-send could charge twice.

Ownership. A provider object changes GuestCraft’s money only through a persisted binding: the object’s identifier, recorded when GuestCraft created or claimed it (a page’s session, a payment’s intent, a refund’s identifier), in the workspace and organization, made with the connection’s account and mode. A payment of a link’s page is created only from the page GuestCraft recorded, and only for the intent or setup Stripe’s read of that page names. Metadata in a provider object is a copy of what anyone with access to the account may write, so it is never proof of ownership: it only finds a local operation that claimed the object before the provider’s answer was lost (a page still creating, a charge or refund still submitting), and only when every fact that operation recorded matches the object: for a saved-card or OTA card charge, the amount, currency, payment method, customer and capture method (a hold or a charge, which is how the purpose shows at Stripe); for a refund, the amount, currency, the payment intent and the refund’s own marker; for a page, its request and customer, the session mode and the amount and currency it asks for (amount_total), and the intent that page names must ask for the link’s amount, currency and customer and be taken as the link’s purpose takes it. Any other object stays unowned, is audited and moves no money. An intent or setup that names one of GuestCraft’s pages leads to a new read of that page by its recorded identifier, never to the naming object itself. Every snapshot must be of the connection’s mode, and the local operation of the connection’s account and mode: Stripe’s Refund has no livemode, and a key reads only its own mode’s objects, so the adapter gives a refund the mode of the key that read it. A delivery about an object no local operation owns changes nothing; it is settled as unowned and audited (payment_event.unowned) (review of PR #305, design review pitfalls 01 and 05).

Applying provider facts finds the property of the object without locking, then takes the property gate before any payment row, in the order every staff command on payments uses, so a guest’s payment settling while staff cancel its link waits instead of deadlocking.

The answer to GuestCraft’s own call records only the provider’s identifier (and a definite refusal); a browser coming back from Stripe settles nothing. This is the brief’s webhook rule with its one documented exception: a provider read settles an unknown outcome (design review, pitfall 02).

Money is booked once. Each financial effect has one folio posting key derived from the provider object that carries it: provider:payment:{payment intent} (one capture per intent), provider:refund:{refund}, provider:refund_failed:{refund}, provider:dispute:{dispute}. The ledger’s unique posting key per reservation makes a second observation of the same effect, by another event or by a read, find the movement already booked. A payment intent belongs to one provider payment of the workspace (unique), so it cannot be booked on two reservations. The provider: and cash: prefixes are reserved: a client can never post them, and the manual posting validator still accepts only the four manual methods.

Provider movements are source: provider with provider, providerReference (the intent, refund or dispute) and paymentRequestId. They change only through the provider: a manual refund of a provider payment answers folio.provider_payment, and a manual reversal folio.not_reversible. They are dated on the property’s business date when applied, so a lagging night audit never makes a real payment unbookable. They are not cash: the drawer never counts them, and the cash report shows them by method, once.

A link is GuestCraft’s address (/pay/{token}, 256-bit token), with its own expiry: the workspace’s link lifetime, 7 days by default, 1–30 days (coordinator decision). Opening it shows the property, the purpose, the amount and the expiry; only the button (a POST, so that messaging apps previewing a link create nothing) creates a fresh Stripe Checkout Session, reused while it has more than 30 minutes left, never living past the link or Stripe’s 24 hours. A link has one payable page at a time: before a page with 30 minutes or less left is replaced, its session is expired at Stripe (POST /v1/checkout/sessions/{id}/expire, under the page’s own idempotency key) and the expiry recorded. While that answer is unknown, or the session is still open, no other page is made and the guest sees that the page is being prepared; a session the guest completed meanwhile is confirmed by its signed event, and no other page is made. Opening claims the page under the link’s lock before Stripe is called: at most one page of a link is creating (a database constraint), numbered per link (attempt), and Stripe’s idempotency key is the link’s identifier and that number. A claim younger than a minute belongs to the opening that made it, and another opening answers that the page is being prepared; an older claim is taken over and asked for again under the same key and parameters, so Stripe answers the same session; reconciliation finds a claimed page whose answer was lost by its marker. Concurrent openings therefore make one session. The guest’s Stripe customer is recorded the same way: the first request is recorded before the call with its time and an unguessable idempotency key, which the customer also carries as a marker (guestcraft_customer). After a lost answer the customers naming the link are looked for in the connection’s own account (GET /v1/customers, newest first) before the customer is asked for again, so a request Stripe no longer remembers never makes a second customer. Only a customer that carries the recorded key and was created while that request could make it is adopted, and only if it is the one: customers that name the link without that proof, or two with it, adopt nothing and create nothing. The link then stays “being prepared” for the guest and shows the operator why (customer_unverified, audited payment_request.customer_unverified once); the operator cancels it and sends a new one. Every page is tracked until it closes; a page still open when its link no longer collects is expired at Stripe by reconciliation, and a payment that still completes on it is recorded, never dropped. There is no e-mail, SMS or Channex Messages path: staff copy the link or show its QR code. The page sends no referrer, is not indexed, cannot be framed and runs no script.

Each folio has at most one open link per purpose (a database constraint); an open link whose time is up gives its place to a new one. When the guest opens a link, its amount is checked again against what is still due: a link that a manual payment, another collection, a waived fee or a cancelled stay has covered is withdrawn (cancelled, failure no_longer_due) before any provider page exists. Links carry purposes prepayment, balance, penalty, damage_deposit and guarantee. A link’s amount is at most what its purpose may still collect, after what is already on its way: the outstanding prepayment obligations of the frozen ADR 0032 schedule, the folio balance, or the live posted fees. A guarantee link saves a card and charges nothing (Checkout setup mode, off-session usage); a prepayment link also saves the card it paid with when Stripe reports that the payment agreed to later off-session use (setup_future_usage: off_session), so the balance and fees can later be charged without the guest; a damage-deposit link places a hold.

Webhook events payment_request.succeeded@1, payment_request.failed@1 and payment_request.expired@1 are published once per transition.

A card is saved only with recorded consent to off-session charges: a successful setup whose usage is off_session, or a prepayment whose payment reports setup_future_usage: off_session. It keeps the provider’s customer and payment method, the brand, last four digits and expiry, and the evidence of the agreement: its source (setup or payment), the provider object and the time Stripe recorded it. No card number or security code exists anywhere in GuestCraft. A saved card confirms the reservation’s rooms whose frozen guarantee is card and still pending, as a reservation.guarantee_updated with source: provider; attested and waived guarantees keep their provenance and their audit.

The per-property payments due list reuses the frozen prepayment schedule of ADR 0032 (due instants, amounts, legacy reconstruction), filtered by the property’s local date, with each reservation’s saved card, open link, collections in progress and last failure. Staff charge the saved card after confirming the amount (payments.collect), or create a link. The amount due is read again under the property lock when the charge is submitted, so a manual payment or a parallel collection never makes it overcollect; one charge of a reservation is in flight at a time, until the provider has settled it. Each charge and each provider refund carries the caller’s posting_key: the same request sent again answers the record it made and never asks the provider twice; another request with the same key answers payments.key_conflict. A charge that needs the guest (authentication required) or is declined shows the provider’s reason; GuestCraft never retries it: staff send a link instead.

A damage-deposit link places an authorization (capture_method=manual). The hold is never revenue and never prepayment coverage: nothing is posted while it holds. Its expiry is Stripe’s capture_before, shown on the reservation. Capturing is one confirmed step (coordinator decision): staff choose the catalog extra and quantity of the damage; the extra is posted as a charge, and exactly its amount is captured, at most what the hold still holds; Stripe releases the rest. A capture with no charge is refused. The payment is posted when Stripe confirms the capture. Staff may instead release the whole hold; a hold that expires is shown as released. Extended holds and several captures are not offered.

Refunds of provider payments go through the provider, from the refunds-due list or the reservation (payments.refund), up to what was received less what is already asked. Each Stripe refund books one refund movement when Stripe reports it succeeded, on the folio the payment is on now (closed or not); a refund that later fails is reversed. A refund is refused while a dispute is open. Disputes (coordinator decision): an open dispute shows the payment as disputed; a lost one reverses the payment with one reversal linked to the dispute, so the balance is owed again (a partial dispute, or a payment already partly refunded, is booked as a refund of the disputed amount); a won dispute changes nothing on the folio. Staff then collect the balance, or write it off through the receivables module once it exists.

Airbnb keeps payments on its platform (its “Pricing and payment transparency” policy): links, off-session charges, holds and its booking cards are refused on Airbnb bookings with payments.off_platform, reason airbnb. Airbnb’s exceptions for some hosts are not supported. Another OTA booking may be collected only when its booking facts say the property collects (ota_collects or collector_unknown otherwise); a null collector is never read as the property’s.

Booking facts. Each accepted Channex revision, right after it is projected, records the booking’s payment facts (booking_payment_facts, one row per reservation, newer revision wins): payment_collect, payment_type, and its card reduced to the card type, the last four digits of Channex’s masked number, the MM/YYYY expiry and, for a virtual card, its currency, balance, effective date and expiration date (BookingRevisions.BookingGuarantee, BookingGuaranteeMeta). The client drops the number, the security code, the holder’s name, the token and every other field while parsing, so they reach no row, archive or log. Unknown or malformed values stay null. The virtual card’s balance is Channex’s digits with its decimal places (6755 with 2 is 67.55), converted to the currency’s minor units only when exact; Channex calls it the card’s initial balance.

Moving and charging the card. Channex’s committed contract (packages/channex-simulator/src/openapi.snapshot.json) documents one operation of its Stripe Tokenization App, POST /bookings/{booking_id}/stripe_payment_method (200 data.token, 401, 403, 404, 422), and neither an application code for the app nor a way to install, list or remove it. GuestCraft therefore infers no lifecycle (coordinator decision): the operator installs the app in Channex’s own interface, where Channex charges for it, and then acknowledges in GuestCraft that they did and accept its cost (payment_tokenization.acknowledged, audited with identifiers only). Only then may staff move and charge the property’s OTA cards. A stop fences every local move and charge of the property’s OTA cards in its own transaction (payment_tokenization.stopped) and leaves the app in Channex for the operator to uninstall there; nothing promises that Stripe methods Channex already made are revoked. Whether the app is really installed is known only from the documented endpoint: its 403 means the app is missing (or not allowed) on the property, so the move is refused (payments.tokenization_missing), keeps nothing and moves no money, and the property’s tokenization records when Channex last refused (missing_at) for Settings and the reservation’s card panel; a move Channex answers, or a new acknowledgement, clears it. “Move the card to Stripe” first requires the booking’s last four digits: unknown or malformed ones match no card, so the move is refused before Channex is asked (payments.ota_card_unavailable, reason unverifiable). It then calls the endpoint, reads data.token as the Stripe payment method, and keeps it only when the workspace’s Stripe account has it, as a card of the connection’s mode, with the booking’s last four digits. Staff then charge it off-session, with no customer (method: ota_virtual_card for a virtual card, else card_online), under the ownership rule: the charge is recorded submitting before the call and a lost answer is found among the account’s recent charges by its marker and the card’s method. A virtual card is charged only from its effective date to its expiry on the property’s business date, in its currency and within its initial balance less what GuestCraft charged or is charging on it; a guest’s own card only when the property collects. A balance, date or currency Channex did not give refuses the charge (payments.ota_card_unavailable, reason unknown). A virtual card charge is the OTA’s settlement of the booking, booked once as provider:payment:{payment intent}: finance work must read that movement rather than record the same money again as an OTA receipt. Tokenizing an OTA card is no new agreement of the guest’s and no claim of zero PCI scope: the deployer keeps its own PCI validation duties. Stripe does not document whether a payment method of no customer can be charged twice; a live account may refuse a second charge, and staff then move the card again.

Receivables (ADR 0051) move a folio’s owed balance to its counterparty’s open item when the folio closes to it. A folio with such a transfer in force owes the guest’s collection nothing: a link or a saved-card charge on it is refused (folio.closed), and a link opened on it before the close is withdrawn when the guest opens it (no_longer_due), before any provider page exists. A payment the provider already took on such a folio is still booked on it, once, as its credit, which the refunds due list then shows: the money is never dropped. A folio closed by the last check-out with an approved open balance still takes a link, which is how its balance is collected afterwards. Cash settlements attribute only the cash handed over to the open drawer; their rounding line never counts in the drawer’s expected amount, which also counts receivables’ cash receipts.

A virtual card charge is the OTA’s money for an OTA-collected booking, booked on the folio as ota_virtual_card (the seam of the folio files). It is also the OTA’s payment of the booking’s expected payout (owner decision): in the transaction that applies the provider’s signed event or GuestCraft’s read of the charge, receivables record one receipt of the OTA by card, marked with the provider payment (provider_payment_id, source: virtual_card in its audit), and allocate it to the payout item as far as the item still owes. A difference stays visible: what the charge did not cover stays open on the item, and what it paid beyond the item stays available on the receipt; nothing is written off. A refund or a lost dispute of the charge releases the allocation and voids the receipt, and a new receipt of what the OTA still paid is recorded and allocated, so the receivables always follow the charge. The receipt is applied under the charge’s lock and the database keeps one live receipt per provider payment, so the event and a read applying the same charge at once record it once. No member voids such a receipt (receivable.receipt_from_provider), and the operator never closes that expectation by hand. A booking with no expected payout (one the property collects) records nothing in receivables.

Settling a folio wholly in cash (POST …/cash_settlements) rounds the total paid or given back with the country pack’s payments.cash_rounding rule for the payment date, found through the property’s new optional country. The cash movement records the cash that changed hands and the difference is its own movement (source: rounding, method cash_rounding, linked to the cash movement), so the drawer counts only real cash and the cash report shows the rounding apart:

Case Cash movement Adjustment
balance 10.02 → 10.00 payment −10.00 payment −0.02
balance 10.03 → 10.05 payment −10.05 refund +0.02 of that payment
credit 10.02 → 10.00 given back refund +10.00 refund +0.02 of the same payment
credit 10.03 → 10.05 given back refund +10.05 payment −0.02

Refundable amounts stay exact (a rounding adjustment of a refund counts toward what the payment gave back), reversing the cash movement reverses its adjustment, and neither moves alone. Without a pack, outside its dated coverage, in another currency or where the rule says none, the exact amount is settled and the answer says why. Partial cash payments are not rounded: the rule applies to a total paid wholly in cash. Prices offered to consumers (prices.consumer) stay unconsumed.

payments.configure (workspace, administrators, browser only: no API key, token or client session holds it), payments.collect (front desk, accounting, administrators: links, saved-card charges, captures and releases) and payments.refund (accounting, administrators). Reads follow folios.read; the payments due list policies.prepayments; cash settlement the existing posting grants.

  • Stripe is the only adapter. Payment terminals, Channex’s Payment App for other providers, payout reconciliation and the Italian RT link (ITA2) are out of scope.
  • A webhook endpoint misconfigured at Stripe delays outcomes until reconciliation reads them.
  • An event received while the workspace is disconnected cannot be verified and is refused.
  • Chargeback accounting beyond the reversal, and Airbnb exceptions, need owner decisions.
  • OTA virtual card charges were verified against the Channex and Stripe simulators only; the Stripe Tokenization App’s 403 when it is missing, the account Channex makes payment methods in, and a second charge of a no-customer method need a check on a dedicated test property.