Charges and Refunds
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:
- 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: 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) runs RefundPaymentAction. The request is RefundRequest:
export interface RefundRequest {
paymentId: string;
amount?: Money;
reason?: string;
}
paymentId is the local payment id. Omit amount for a full refund; pass a Money for a partial
refund.
// full refund
await payable.refund({ paymentId: payment.id });
// partial refund
await payable.refund({ paymentId: payment.id, amount: Money.of(4000, 'USD') });
RefundPaymentAction.handle:
- Requires a storage driver (
PAYMENT_STORAGE_REQUIRED). - Loads the payment by id; throws
PayableError(PAYMENT_NOT_FOUND) if it is missing or has noproviderPaymentId. - 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. - Calls
provider.refund({ providerPaymentId, amount, reason }, ctx). - Rejects a currency mismatch: if the refund DTO currency differs from the payment currency, throws
PayableError(REFUND_CURRENCY_MISMATCH). - Persists a
refundsrow. - 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.
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] --> B{payment found?}
B -- no --> E[PAYMENT_NOT_FOUND]
B -- yes --> C{provider refunds capable?}
C -- no --> F[ProviderCapabilityNotSupportedError]
C -- yes --> D[provider.refund]
D --> G{currency matches payment?}
G -- no --> H[REFUND_CURRENCY_MISMATCH]
G -- yes --> I[persist refund]
I --> J{refundedAmount >= payment.amount?}
J -- yes --> K[status = refunded]
J -- no --> L[status = partially_refunded]
Policies
CanRefundPaymentPolicy, CanCreateCheckoutPolicy, and CanCreateSubscriptionPolicy all authorize
against an AuthorizationContext: isAuthorized returns true only when allowed === true and
actorId is a non-empty string.
These policies are defined but not yet wired into ChargeAction, RefundPaymentAction, or the
checkout pipeline - none of the actions or pipelines reference them, and only CanReplayWebhookPolicy
is consumed (by ReplayWebhookAction). They are reusable authorization helpers for integrators to call
in their own controllers; today the charge and refund paths perform no actor-level authorization of
their own.
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. - Refund exceeding the remaining balance. Not blocked by a dedicated guard in this version: the
action forwards
amountto the provider and, after persisting, marks the paymentrefundedoncerefundedAmount >= payment.amount. Enforcement of an over-refund relies on the provider rejecting it; there is no local “already fully refunded” precheck.