Payments on shared billing accounts
Submit payments, count them while pending, and deliver lifecycle facts to Billing with durable retries.
- Domain
- Services
- Kind
- Go HTTP service
- Lifecycle
- Active
- flowctl project
- None
- Stacks
- Runs on its own
- Source
- services/payments
Payments owns processor collection and payment lifecycle. Billing owns allocations and their
reversals. Every payment belongs to a billing account. Supplying payment_method_token or
payment_method_id schedules a provider charge. Without either, the payment records money
already received and commits with Approved status.
Off-platform recordings require a nonblank reference and transaction_date in YYYY-MM-DD format: the day the resident paid. The staff payment form asks for this date. Payments preserves it without timezone conversion, includes it in lifecycle events and Billing delivery, and uses it for the journal entry date. created_at remains the server audit clock. Before recording, Payments reads Accounting's verified entity calendar and refuses a date after the entity's current day; a missing calendar returns 503 without recording anything. An identical retry replays the stored result without requiring the calendar again.
Processor charges must omit transaction_date; supplying one returns 422. Their original server submission time determines whether they were on time in the entity calendar. Billing keeps that instant or the recorded calendar date across approval, application and delivery retries. Historical off-platform dates cannot be inferred from entry creation time and remain visibly unknown; see late fee payment-date recovery.
Pending payments count immediately. After submission, Payments attempts the same cash and AR
journal posting used for an approved payment, and delivers the submission to Billing. Neither
operation runs inside the recording transaction. A failed accounting call does not prevent Billing
from registering and allocating the payment. Approval changes collection status without another
cash entry or another allocation. posted_at reports completion of the journal posting, separately
from processor status.
Payments delivers payment.recorded, payment.approved, payment.posted, payment.failed, and
payment.returned from its durable outbox. payment_events.billing_delivered_at records each
acknowledgement independently of broker publication. Partial indexes contain only outstanding
lifecycle deliveries, so polling and per-payment ordering do not scan acknowledged history.
Delivery uses a stable event-specific idempotency key and holds later facts for the same payment
until earlier facts are acknowledged. A failed payment therefore cannot overtake its submission.
Failures on one payment do not stop delivery for others. A failed pass returns an error and logs
the payment ID and event ID. The request path attempts delivery immediately; the background loop
retries outstanding facts every minute. There is no retry limit or automatic dead-letter bypass:
a permanently rejected submission holds that payment's later events, including its reversal.
Repair the rejection, then let the same event retry with its original idempotency key. Never mark
an undelivered event acknowledged to unblock the queue; Billing may already have committed it.
Rollout includes existing Pending payments, including indeterminate processor attempts. Migration
0024_payment_billing_delivery acknowledges the registration/approval/posting events of receipts
already delivered under the previous integration; it does not acknowledge undelivered submissions.
Those submissions adopt immediate allocation and posting on the first retry after deployment.
There is deliberately no date or event-ID cutoff. Later failure outcomes still reverse their
allocations. This catch-up can change balances at deployment and must be included in rollout
reconciliation.
Billing allocates the initial submission in the receipt registration transaction. An optional
lease_id limits allocation to that lease, including later use of its remaining prepay.
preferred_invoice_id or preferred_charge_id selects an invoice or security deposit before the
normal allocation order. Billing validates the selection before Payments schedules a charge and
checks it again during registration. Without a lease, allocation can span the billing account's
associated leases. Money never moves to another billing account.
The internal processing_fee_charge_id is groundwork: no current Payments submission path
populates it, and the public creation body does not accept it. It names the Billing charge to
pay first once a fee producer is integrated. That producer must create the charge before recording
the payment and supply its ID through the domain input.
This integration does not configure fees, create fee charges, cancel them, or change refund amounts.
The current allocator requires an issued invoice for a charge to be allocatable; the separate fee
implementation must reconcile this with the requirements for fees that do not go on an invoice.
Declined, Voided, and Returned outcomes withdraw the Billing receipt and reverse its applications, including later applications of its prepay. Billing preserves the outcome timestamp across retries. Payments also reverses the original journal entry using its existing period checks and bounded roll-forward. A payment that failed before its original posting completed is posted and reversed by the sweeper. Voided remains distinct from Declined. A confirmed refund continues through the existing refund/reversal workflow.
Posting references arrive independently: a later payment.posted attaches the original journal
entry ID without allocating again. The reversal sweeper attaches the posted reversal reference to
the same reversal. Billing retains those references for deletion and period controls.
Staff-recorded payments require a nonblank reference and a transaction_date in YYYY-MM-DD
format. The date is stored as a calendar day and passed unchanged to Accounting as the entry date;
created_at remains the time the record was entered. Payment reads and lifecycle events include
transaction_date when recorded. Older payments and processor payments have no separately stored
transaction date; their posting retains the existing created-date behavior. A processor charge
cannot supply a transaction date.
Staff-recorded creation returns approved once the date is validated, even if the later Accounting posting is unavailable. Processor creation
returns pending. Replay returns the originally stored response and retries unfinished posting and
Billing delivery. Existing command responses remain replayable without the new fields. Replaying
a provider payment never approves it or creates another processor attempt.
The existing journal posts the whole payment to AR. Splitting excess into the GL prepay liability, fee charge cancellation, partial refunds, won disputes, and the new current-Open-month policy are not implemented by this lifecycle change. Billing's operational prepay and invoice balances remain the allocation records. See Billing and the ongoing prepay and accounting-period work before treating those outstanding behaviors as complete.
services/payments/openapi.yaml defines the API.
Saved-method setup
A SetupIntent is Stripe's record of preparing a payment method for future use without charging it.
The Stripe adapter creates and retrieves these records for cards and US bank accounts through
BeginMethodSetup and GetMethodSetup. Confirmation and authentication belong to Stripe's browser
SDK. Bank setup requests instant verification with microdeposit fallback and only the Financial
Connections permission needed to create a payment method.
The provider contract uses pending, action_required, processing, succeeded, canceled and
unknown states. Stripe's original status is retained separately for diagnosis. Unknown statuses
return no usable method or browser handoff data. Retrieval checks the expected setup ID, owner and
processor customer; mismatches return ErrSetupMismatch naming the failing field without its value.
Only a succeeded setup with matching method type and customer returns a usable credential.
Card metadata includes funding type and billing address. The address is PII that callers must
protect. Bank metadata includes bank name, account type, last four and mandate reference.
GetMethodSetup returns the client secret again when input, confirmation or authentication is
needed. Callers must authorize access to the setup before exposing this processor-specific browser
handoff data. Client secrets and hosted verification links are excluded from JSON serialization of
the internal result.
HTTP errors retain their status and Stripe error code/type, excluding response bodies and messages.
Transport errors preserve their cause and report an unknown outcome. Only local input validation
sets CaptureConfirmedNone; a remote error does not establish that nothing happened.
The adapter limits setup idempotency keys to 246 bytes, reserving nine for the customer-key suffix
within Stripe's 255-character limit.
Durable card setup API
Card setup is owned by the paymentmethod domain. It creates no payment or ledger entry.
All setup routes require the existing create-payment-method permission and match the authenticated
owner in SQL. A setup belonging to someone else returns 404.
| Route | Behavior |
|---|---|
POST /v1/payment-method-setups | Takes {data: {legal_entity_code, kind: "card"}, why?} and an Idempotency-Key. Records the route and queued work atomically, then returns 201 with the setup ID and initial pending state. |
GET /v1/payment-method-setups/{id} | Reads local status: pending, ready, canceled or requires_reconciliation, the last observed processor status, and the saved method ID once ready. |
GET /v1/payment-method-setups/{id}/session | Retrieves Stripe's current setup and returns a client secret only while browser action is required. The browser configures Stripe.js for the returned processor account. The response is Cache-Control: no-store. |
POST /v1/payment-method-setups/{id}/refresh | Takes {data: {}} and an Idempotency-Key. Schedules a worker read after browser confirmation. A new refresh uses a new key; retrying the same refresh replays its original response. |
Start and refresh use the domain command executor. Replaying start returns its original pending response, even after completion; GET returns current local state. Client secrets are retrieved on demand and never stored in setup rows, command responses or events. Browser confirmation alone cannot activate a method.
The existing Payments worker claims setup work with a lease and version. It calls Stripe outside
transactions, then accepts the result only if that version still holds an unexpired lease.
Creation retries use the same processor key and account snapshot. Once the processor reference
is recorded, recovery retrieves that same setup. For pending or action-required setups, the next
background check is delayed by the setup's age, with a minimum of five minutes. Without resident
activity, the intervals therefore double as the setup ages; a week-old setup waits another week.
Unknown processor states use the same backoff. Setups are not canceled because of age.
processing is checked every thirty seconds. Refresh makes a setup due immediately without
stealing a lease, so a resident can resume and complete an older setup.
Transport failures, HTTP 401/403/408/409/429, and server errors retry after thirty seconds with a safe error
code. Authentication and permission failures can recover after processor configuration is repaired;
they do not classify the resident's instrument as invalid. Each persisted retry emits a warning with
setupId, the create/retrieve stage, a fixed reason code, setup age in seconds, and nextAttemptAt.
The warning excludes processor error text, bodies, credentials, and owner details. Operators can
find stuck setups by setup ID and age, and configuration failures by processor_configuration or
processor_authentication. The creation safety window below still applies.
Other HTTP 4xx responses, local validation failures, unsupported operations, and setup identity
mismatches stop automatic work in requires_reconciliation. The setup records a fixed reason code;
the worker logs the setup ID and that reason without processor messages, bodies or credentials.
Refreshing a setup cannot restart it from this state.
If creation remains uncertain for 23 hours without a stored processor reference, the setup becomes
requires_reconciliation. Automatic creation stops before Stripe's minimum 24-hour idempotency
retention ends (see Stripe idempotent requests).
There is no operator reconciliation endpoint; recovery of that case requires
investigating the processor result. It must not be treated as proof that no setup was created.
Verified completion atomically inserts the saved card, its funding type and billing address,
the paymentmethod.added event, and the setup's method reference. Setup status changes emit
paymentmethod.setup_changed without credentials or display details. A ready or canceled setup
is never restarted; repeating completion after method removal cannot recreate the method.
The public POST /v1/payment-methods still uses synchronous StoreMethod. Existing saved cards
have unknown funding and an empty billing address unless created through the new setup flow;
these values are not inferred. Card details remain owner-scoped and stay out of events.
The setup API supports Stripe cards only. Bank setup is available at the adapter boundary but
has no public enrollment endpoint. Checkout.com setup remains unsupported. Saving a method
does not enroll it in autopay, and this API does not provide the browser UI.
Processing fee foundation
The payment domain has internal functions for storing one versioned fee schedule per legal
entity and calculating fee quotes. Schedules use legal_entity_code, matching the existing
payment, Billing and routing contracts. Moving these references to Accounting's UUID is
part of the separate, coordinated legal-entity migration.
A schedule is read and set through GET and PUT /v1/fee-schedules/{legal_entity_code}. The
staff, senior-accountant, controller and platform-admin roles read one; only
controller and platform-admin set one, matching every other configuration write in
accounting and billing, including the charge code a rate posts under. Saving one checks
the legal entity, its currency and the charge code against the services that own them, before
the write opens its transaction, so a wrong charge code is refused when an accountant saves
rather than when a resident pays.
Nothing quotes a fee at payment time yet. Payment creation is unchanged and adds no Accounting entity lookup.
Each schedule holds a currency, the immutable ID of a charge code, and a flat or percentage rate for credit cards, debit cards and bank accounts. Wallets select the credit-card rate. Flat rates are whole currency minor units; percentages are exact decimal strings between 0 and 100. Zero disables the selected rate. Calculations check currency and integer overflow, and reject an unknown card funding type. A missing schedule is reported explicitly; it does not silently mean a zero fee.
Updates require the previous version and run in the payment command transaction. Concurrent
edits cannot overwrite one another. The payment.fee_schedule_changed outbox event records
the previous and new configuration, actor and timestamp. Updating a schedule posts no money.
A quote contains the schedule ID and version, selected rate, charge-code ID, rounding rule, base,
fee and total.
The payment submission workflow must persist that quote before charging a processor so later
configuration changes cannot alter retries or reversals.
Percentage fees use exact decimal arithmetic on the base amount and round once to the nearest
minor unit, with exact halves rounded up (half_up, user decision on 2026-10-09). Flat rates
already use whole minor units. A calculated zero fee creates no fee charge or receipt fee line
when submission is connected.
The remaining integration requires Billing to support fee charges outside invoices and linked negative corrections. Billing owns fee-first allocation, retention of the paid fee during refunds and partial reversals, and restoring the fee when a reversal is removed. Payments must send the stored fee facts alongside submission and lifecycle changes, without reproducing those allocations. The present Billing charge API schedules charges for invoices and identifies charge types by text; the requirements instead call for immutable charge-code IDs. The new schedule stores the ID and does not guess a mapping to the current text contract.
Unknown funding types and tax on a fee-bearing charge code must be resolved before enabling collection. No partial-refund initiation is added.
Delete a mistaken payment
DELETE /v1/payments/{id} requires the separate DeletePayment permission,
an Idempotency-Key, and {"data":{"reason":"Duplicate payment recorded"}}.
Controllers and platform admins hold this permission. Deletion corrects the recorded
payment; it does not send money back through a processor.
The payment must be approved, fully posted, and registered with Billing. A refund that may have returned money or an existing reversal prevents deletion. Billing checks the payment's posted month and affected application months before removing its receipt. The Accounting delete command then removes all payment and related prepay journal entries together, after checking their periods and reversals. It retains each entry and its lines in an audit snapshot and posts no replacement entry.
Deletion is limited to logged manual, check, and wire records with no processor attempt. Card and ACH payments must use the refund flow; this route cannot erase a captured payment without returning money.
Payments commits the deletion intent before either HTTP call. A pending deletion
blocks a second delete or refund. Payments first asks Accounting to check deletion permission, entry relationships,
reversals and periods before removing Billing allocations. This check does not
reserve Accounting state; deletion rechecks it when it commits. Downstream outages
return 503; retry with the
same key. Committed Billing results and journal references let retries resume
Accounting cleanup without repeating allocation removal. The API also retries
unfinished requests in the background, using the configured service clients.
Background calls to Billing use BILLING_SERVICE_AUTHORIZATION, an Authorization header
accepted by Billing for a principal allowed to delete receipts. Local runs use the guarded
payments dev actor. Without either, the worker leaves requests needing Billing pending;
an authenticated caller can retry the same deletion key. Accounting cleanup of receipts
already removed by Billing still runs in the background. Billing authentication refusals
remain retryable after the credential or permission is corrected. Caller Authorization
headers are forwarded for the immediate attempt and are never stored.
Each processing attempt has a 45-second deadline inside its one-minute lease. A timeout leaves the durable intent available for another attempt with the same downstream keys.
Successful deletion returns 200 with payment_id, reason, and deleted_at.
The payment is excluded from reads and lists, with its original record, postings,
Billing snapshot, reason, actor, and time retained for audit. Replays return the
same deletion result, including the persisted deletion time used by the audit event;
replaying its original creation does not restore it.
payment.deleted carries the audit snapshots and removed journal entry IDs.
Its free-text reason and snapshots are internal, PII-bearing audit data. Migration
rollback is refused while deletion audit records exist.
Deletion audits and subsequent processor facts publish to payments-payment-pii-fanout.
Billing's receipt-deletion audit is likewise classified as internal PII data by
migration 0047_receipt_deletion_privacy.sql; it uses the existing Billing PII topic.
Processor notifications received during or after deletion are retained as
payment.deletion_provider_fact events. They do not recreate deleted postings.
Controllers can review these facts through GET /v1/payment-deletion-alerts,
using pagination[limit] and the returned meta.cursor.next as
pagination[cursor]. The facts retain refund amounts and processor references;
an audit write failure leaves the delivery unprocessed for retry.
A permanent Accounting or Billing refusal before Billing commits leaves the payment
eligible for a new request after the cause is fixed. The refused key continues to
return that refusal. If Accounting refuses after Billing commits, the API returns
that refusal's 4xx status and retains the active intent. The background worker
stops retrying it; correct the cause and retry the same deletion key to finish
cleanup. Other deletes and refunds remain blocked during incomplete cleanup.
Payment deletion requires Accounting's deletion API and Billing's
prepay_journal_entry_ids response field, including an empty array when none
exist. Payments refuses a response that omits the field. Every removed payment
or prepay journal entry must identify its payment through payment_id.
Staff can read a retained receipt through
GET /v1/billing-accounts/receipts/{payment_id}. It returns status: deleted, the
original amount and posting reference, recorded time, and deletion reason, actor
and time. Older deletion audits that predate the original-receipt snapshot have
no recorded time. Deleted receipts remain excluded from allocation and balances.
Accounting currently models periods as open or closed. Differentiated soft-close permissions for controllers and bank reconciliation locks require the corresponding platform state and are not supplied by this deletion change.
This endpoint removes an approved logged payment. It does not record partial reversals or partial refunds. Billing can undo a receipt reversal, but Payments has no command that also removes its Accounting reversal and restores the payment status. These are separate operations from deleting a logged mistake.