Refunds use integer minor units, reserve refundable capacity before an external call, post accounting entries only on an accepted provider result, and reconcile uncertain outcomes by a stable provider identifier.
Package status: reference context ready for human review. The contract and test scenarios are complete, but no claim is made that an adopting implementation has passed them.
Decision
A refund starts as an intent and reservation against a captured charge, using integers in minor units. The sum of settled refunds and active reservations cannot exceed the captured amount. The service commits the intent and reservation before the external call, sends a stable provider idempotency key and request fingerprint, and posts accounting entries only after the provider accepts the refund. Provider reports remain the external settlement evidence and are reconciled by provider refund identifier.
Scope
- Refund commands for card and wallet captures: partial and full, ledger postings, idempotency, provider reconciliation, and failure states.
- Backend payment services that already store captured charges in minor units.
- Interaction with an external processor identified by
provider_refund_id.
Outside this block
- Card capture, 3-D Secure, or checkout session UX.
- Currency conversion and rounding of multi-currency displays.
Contract
- Refund amounts are integers in minor units; floating-point types are rejected at the API boundary.
sum(settled refunds) + sum(active pending reservations) + requestedmust be less than or equal tocaptured_amountor the command returnsrefund_exceeds_capture.- The tuple
(merchant_account, charge_id, idempotency_key)maps to at most one refund intent, request fingerprint, provider operation, and eventual accounting batch. - Creating an intent reserves refundable capacity but does not create settlement ledger lines.
- An accepted provider refund stores
provider_refund_idand posts a balanced accounting batch for the refunded minor units; a later settlement report confirms or reconciles that batch. - Partial refunds leave the charge
captureduntil remaining captured amount is zero, thenrefunded. - Reconciliation jobs match ledger
provider_refund_idto provider reports; unmatched rows are exceptions, not silent deletes.
Implementation
- In one database transaction, lock the charge, check settled plus active reservations, create the refund intent, and reserve the amount. Call the processor only after that transaction commits.
- Derive a stable provider idempotency key from the stored intent and keep the same normalized request fingerprint across retries.
- Use integer minor units only; apply provider webhooks through the same idempotency map.
Failure handling
- Processor timeout: keep the intent
pending-unknown, reconcile the original provider operation before retrying, and reuse the same key and fingerprint. Do not insert accounting lines or release the reservation while the outcome is uncertain. - Processor decline: mark the intent
failed, release its reservation, and create no settlement lines; a materially different attempt needs a new key.
Verification
- Concurrent refunds that would exceed capture must leave at most the captured amount split across settled refunds and active reservations.
- Replay the same idempotency key and expect the same
provider_refund_id; reject floating-point amounts.
The executable-looking examples in this package are fixtures and acceptance contracts. Run
python tools/validate.py from the collection root to check package structure and metadata; then
implement and execute the scenarios in the target repository.
Adoption assumptions
- Names and numeric values in
example.yamlare an adoption profile, not universal defaults. - The adopting team must map actors, data classes, error vocabulary, and ownership to its system.
References
- Amounts use integer minor units as a project decision; currency identifiers follow ISO 4217. Currency exponents and provider-specific exceptions must be versioned in the adopting system rather than inferred from floating-point values.