Flow
ServicesPayments

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

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.

RouteBehavior
POST /v1/payment-method-setupsTakes {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}/sessionRetrieves 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}/refreshTakes {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.

services/payments/internal/handlers/payment_method_setups.go services/payments/internal/worker/method_setup.go services/payments/internal/provider/stripe/setup.go

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.

On this page