Charges and Refunds
Canonical local collections
After configuring storage, payable.storedPayments(tenantId) can record funds collected outside a
payment adapter. record() requires a canonical customer, money, an explicit pending or
succeeded status, and a structured collection method such as cash, bank_transfer, or cheque.
The resulting payment has provider and providerPaymentId set to null; provider identity is
routing metadata and must never be fabricated for a local collection.
Pending local payments support succeed(id) and the public void(id) operation. Void is persisted as
the existing canonical canceled payment status for state-machine compatibility. refundLocal()
records a full or partial return already performed outside a provider, creates an independently
addressable refund with null provider identity, and atomically updates the payment’s refunded amount.
It can also record a return already completed outside Payable for a provider-backed payment. That
case requires confirmedExternally: true and a non-blank externalReference; it never resolves or
calls the payment provider. Local and provider-backed refunds reserve the same canonical refunded
amount with compare-and-swap updates, so concurrent operations cannot exceed the payment total.
All local mutations accept an idempotencyKey. When an idempotency store is configured, an identical
key and payload replay the stored result; reusing the key with different evidence returns
IDEMPOTENCY_CONFLICT. HTTP and MCP mutations require a valid key: send exactly one
Idempotency-Key header or the required idempotencyKey argument. SDK callers can omit the key and
intentionally opt out of replay protection. If a local transaction commits but its idempotency result
cannot be persisted, retrying the key returns IDEMPOTENCY_RECONCILIATION_REQUIRED rather than
duplicating the money movement; reconcile the already-recorded canonical resource first.
Canonical refunds are readable through storedPayments(tenantId).retrieveRefund(id) and
listRefunds({ limit, cursor, paymentId }). The list is tenant-scoped, uses opaque bounded cursors,
and is exposed at GET /canonical/refunds, GET /canonical/refunds/:id, and the MCP
canonical_refunds_list and canonical_refund_get tools.
When authorization is enabled, recording, succeeding, voiding, and refunding use the same charge or
refund policies as provider-backed operations. Audit recordedBy values come only from the resolved
authorization actor; they are not accepted from public request bodies.
Collection method does not imply settlement status. Store receipts or bank references in
externalReference; do not overload descriptions with routing identity. Provider-backed charge and
refund operations remain unchanged and continue to carry explicit provider attribution.
A charge is a one-off payment against a customer; a refund returns money against a recorded payment, partially or in full. Both persist locally and track the payment’s lifecycle through the payment state machine.
One-off charge
payable.customer(billable).charge(request) runs ChargeAction. The request is a ChargeRequest:
export interface ChargeRequest {
amount: Money;
reference?: string;
description?: string;
}
amount is a Money value object in minor units (see 06-value-objects).
import { Money } from '@akira-io/payable';
const payment = await payable.customer(billable).charge({
amount: Money.of(9900, 'USD'),
reference: 'inv_1',
description: 'one-time',
});
ChargeAction.handle:
- Calls
assertAuthorizedwithCanChargePolicy, gated whendeps.authorizationEnabledis true (a no-op otherwise), using the optionalauthorizationon the request. - Requires the provider to be charge capable (
isChargeCapable, i.e. it implementscharge); otherwise throwsProviderCapabilityNotSupportedError. - Requires a storage driver (
PAYMENT_STORAGE_REQUIRED). - Syncs the customer to the provider and loads the local customer row; throws
CustomerNotFoundErrorif missing. - Builds a deterministic key with
IdempotencyKey.forChargekeyed by provider, billable,reference, amount, and currency - for examplecharge:stripe:User:1:inv_1:9900:USD. - Calls
provider.charge({ providerCustomerId, amount, reference, description }, ctx). - Persists a
paymentsrow withstatus,currency,amount,refundedAmount: 0, and thereference/description.
Output: the persisted Payment entity.
sequenceDiagram
participant App
participant Action as ChargeAction
participant Sync as SyncCustomerWithProviderAction
participant Provider
participant Storage
App->>Action: charge({ amount, reference, description })
Action->>Action: assertAuthorized (CanChargePolicy, if enabled)
Action->>Action: assert charge-capable + storage
Action->>Sync: handle(billable)
Sync-->>Action: providerCustomerId
Action->>Storage: customers.findByBillable
Storage-->>Action: customer (or CustomerNotFoundError)
Action->>Provider: charge(input, ctx)
Provider-->>Action: ChargeResultDTO
Action->>Storage: payments.create(...)
Storage-->>App: Payment
The provider returns a ChargeResultDTO: { providerPaymentId, status, amount }.
Refund
payable.refund(request, tenantId?) runs RefundPaymentAction. The optional second argument
tenantId?: string | null scopes the lookup when tenancy is in play. The request is RefundRequest:
export interface RefundRequest {
paymentId: string;
amount?: Money;
reason?: string;
reference?: string;
authorization?: AuthorizationContext;
}
paymentId is the local payment id. Omit amount for a full refund; pass a Money for a partial
refund. reference feeds the refund idempotency key via IdempotencyKey.forRefund. authorization
carries the optional AuthorizationContext for the refund call.
// full refund
await payable.refund({ paymentId: payment.id });
// partial refund
await payable.refund({ paymentId: payment.id, amount: Money.of(4000, 'USD') });
RefundPaymentAction.handle:
- Calls
assertAuthorizedwithCanRefundPaymentPolicy, gated whendeps.authorizationEnabledis true (a no-op otherwise), using the request’s optionalauthorization. - Requires a storage driver (
PAYMENT_STORAGE_REQUIRED). - Loads the payment by id; throws
PayableError(PAYMENT_NOT_FOUND) if it is missing or has noproviderPaymentId. - Requires the payment to be
succeededorpartially_refunded; otherwise throwsPayableError(PAYMENT_NOT_REFUNDABLE). - Rejects a currency mismatch: if the requested
amountcurrency differs from the payment currency, throwsPayableError(REFUND_CURRENCY_MISMATCH). - Guards against over-refund:
remaining = payment.amount - payment.refundedAmountandrequested = input.amount?.amount() ?? remaining. Ifremaining <= 0orrequested > remaining, throwsPayableError(REFUND_EXCEEDS_REMAINING) with context{ paymentId, requested, remaining }. - Asserts the provider’s
refundscapability viaassertProviderCapability. - Builds a deterministic key with
IdempotencyKey.forRefundkeyed by provider, provider payment id, amount (defaulting to the full payment amount), and currency. - Reserves capacity: in a transaction it creates a pending
refundsrow up-front and applies the projectedrefundedAmount/statusto the payment, holding the balance before the provider call. - Calls
provider.refund({ providerPaymentId, amount, reason }, ctx). On a provider failure (or a post-call currency mismatch)releaseReservationreverts the payment balance and flips the pending refund row tofailed. - Recomputes
refundedAmount = payment.refundedAmount + dto.amount. UsingPaymentStateMachine, it transitions the payment to refunded whenrefundedAmount >= payment.amount, otherwise to partially refunded; then updates the payment’srefundedAmountandstatus. A settlement-time re-check re-validates the remaining balance to guard against races before the row is written.
Output: the persisted Refund entity.
Partial vs full refund
Refunds accumulate. Charging 9900 then refunding 4000 leaves the payment partially_refunded with
refundedAmount = 4000; a further refund of 5900 makes the status refunded with refundedAmount
= 9900. The full/partial decision is purely refundedAmount vs payment.amount - there is no separate
“full refund” flag.
flowchart TD
A[refund request] --> Z{authorized? - if enabled}
Z -- no --> ZE[authorization error]
Z -- yes --> B{payment found?}
B -- no --> E[PAYMENT_NOT_FOUND]
B -- yes --> R{succeeded or partially_refunded?}
R -- no --> RN[PAYMENT_NOT_REFUNDABLE]
R -- yes --> G{currency matches payment?}
G -- no --> H[REFUND_CURRENCY_MISMATCH]
G -- yes --> M{remaining > 0 and requested <= remaining?}
M -- no --> N[REFUND_EXCEEDS_REMAINING]
M -- yes --> C{provider refunds capable?}
C -- no --> F[ProviderCapabilityNotSupportedError]
C -- yes --> D[provider.refund]
D --> I[persist refund]
I --> J{refundedAmount >= payment.amount?}
J -- yes --> K[status = refunded]
J -- no --> L[status = partially_refunded]
Policies
CanChargePolicy, CanRefundPaymentPolicy, CanCreateCheckoutPolicy, and
CanCreateSubscriptionPolicy all authorize against an AuthorizationContext: authorization succeeds
only when allowed === true and actorId is a non-empty string.
These policies are now enforced at the front of their respective paths via assertAuthorized,
gated by deps.authorizationEnabled: ChargeAction asserts CanChargePolicy (step 0) and
RefundPaymentAction asserts CanRefundPaymentPolicy (step 0), each before any storage or provider
work. When authorization is disabled the assertion is a no-op, so integrators that do not opt in see
no behavior change. Callers pass the AuthorizationContext through the request’s authorization
field.
Edge cases
- No storage driver. Charge and refund both throw
PAYMENT_STORAGE_REQUIRED. - Provider not charge capable.
ChargeActionthrowsProviderCapabilityNotSupportedError. - Provider lacks
refundscapability.RefundPaymentActionthrows viaassertProviderCapability. - Unknown payment id / no provider payment id.
PAYMENT_NOT_FOUND. - Refund currency differs from payment.
REFUND_CURRENCY_MISMATCH. - Payment not in a refundable state. A payment that is not
succeededorpartially_refundedthrowsPAYMENT_NOT_REFUNDABLE. - Refund exceeding the remaining balance. Blocked by a dedicated guard before the provider call:
with
remaining = payment.amount - payment.refundedAmountandrequested = input.amount?.amount() ?? remaining, the action throwsREFUND_EXCEEDS_REMAINING(context{ paymentId, requested, remaining }) whenremaining <= 0orrequested > remaining. A settlement-time re-check re-validates the remaining balance to guard against concurrent refunds.