Status: Proposed for team discussion
Date: 2026-10-14
Scope: Multiple sequential partial refunds against one Chargebee invoice
Out of scope for the first release: Concurrent active refunds against the same invoice, changing the destination between operations, and redesigning excess refunds
hRefunds can select a subset of invoice line items for one refund, but it still treats the invoice as the identity of that refund. A second partial refund of the same invoice is blocked or becomes ambiguous in hRefunds, CRM, and hPayments.
The proposed direction is to separate:
Every operation, including the first, receives an operation UUID. The invoice ID remains the real Chargebee reference and may remain a legacy alias at existing API boundaries, but it is not an operation ID.
The recommended implementation is a refund aggregate with child operations, executed as an orchestrated saga by hRefunds. Operations reserve invoice items and payment amounts before submitting the financial refund. Each downstream call is idempotent and correlated by the operation UUID.
| Term | Meaning |
|---|---|
| Refund case | The aggregate for all refunds against one original invoice. |
| Refund operation | One requested refund with its own amount, selected items, lifecycle, and idempotency key. |
| Payment attempt | One operation's refund against one original payment leg. |
| Reservation | Amount and items temporarily claimed by a non-terminal operation. |
| External reference | A provider refund ID, CRM operation reference, or Chargebee credit note ID belonging to an operation. |
Required invariants:
RefundsRepository::createFromDto() and createFromArray() use updateOrCreate() with the invoice-like identifier. A later operation would overwrite or reuse the first row.getRefundByInvoiceId() returns a row.receipt_id and updates status by receipt.RefundDataManager::getRefundByPaymentToken() deliberately returns no refund when a token maps to multiple refund rows.refund_items uniqueness is scoped to (refund_id, line_item_id), not across operations for the invoice.Relevant implementation anchors:
app/Repositories/RefundsRepository.phpapp/Services/Managers/RefundDataManager/RefundDataManager.phpapp/Services/RefundService/Handlers/InitiateAutomaticRefundHandler.phpapp/Services/Fraud/FraudRefundService.phpapp/Services/ExternalServicesWrapper/Services/HPayments/Webhooks/RefundedWebhookHandler.phpapp/Services/PostWebhookHandlers/Handlers/RegularPostHandler.phpapp/Services/History/HistoryBuilderService.phpapp/Modules/Hrefunds/Presentation/Http/Controllers/Api/RefundRequestsController.phpapp/Service/Webhook/Contents/PaymentRefundWebhookContent.phpThe schema already has most of the child records needed for an operation. The missing concept is the parent case and an end-to-end operation identity.
Major payment APIs model a refund as a separate resource that references the original payment:
This leads to the same general model:
original payment or invoice
-> refund operation 1
-> refund operation 2
-> refund operation 3
References:
Example: H_123, H_123-addon-1, H_123-addon-2.
Rejected as the domain model. Chargebee and several CRM paths expect a real invoice ID. It also makes the first operation a permanent special case and does not solve webhook correlation. A derived label may be used for display, but not as the authoritative operation identity.
Store multiple attempts or batches inside metadata and continually update the invoice refund.
Rejected. Independent amounts, statuses, retries, credit notes, observability, and audit history cannot be represented safely. Updating the parent also makes duplicate callbacks and partial failures difficult to reconcile.
Represent the aggregate entirely as an event stream.
Not recommended initially. An append-only audit trail is useful, but rebuilding all refund state from events is unnecessary for this change and would substantially enlarge the migration.
Recommended. It matches provider models, maps naturally onto the existing refund child tables, and gives every partial refund an independent state machine while preserving the invoice as the common parent.
The target semantics do not require immediately renaming large tables:
| Target concept | Initial physical implementation |
|---|---|
refund_cases | New table. |
refund_operations | Existing refunds table plus refund_case_id, operation_uuid, sequence, idempotency_key, and destination. |
refund_operation_items | Existing refund_items; add an amount/allocation state only if item-level amounts must be reserved explicitly. |
refund_operation_subscriptions | Existing refund_subscriptions. |
refund_payment_attempts | Existing refund_payments; add provider_refund_id and operation_reference. |
refund_credit_notes | New table, because one operation may create several credit notes. |
refund_operation_steps | Existing refund_steps_changelog. |
| outbox/inbox | New tables or an approved existing messaging facility with equivalent durability and deduplication. |
Suggested constraints and indexes:
refund_cases: unique invoice_id.refund_operations: unique operation_uuid; unique (refund_case_id, sequence); unique idempotency_key.refund_payment_attempts: unique provider refund ID when present; index operation_reference; index original transaction identifiers.refund_credit_notes: unique credit_note_id; index operation_id.The exact distinction between Failed, Rejected, and a retryable technical failure needs agreement. The important boundary is whether hPayments/provider execution may already have moved money.
operation_id when a refund is accepted.invoice_id and operation_id.Required request fields:
operation_reference: stable hRefunds operation UUID.Required response/event fields:
operation_reference;operation_reference and provider refund ID in completion/failure webhooks;hPayments already accepts optional operation_reference, but current local source uses it only in the Razorpay client and does not include it in REFUND_COMPLETED. Every enabled gateway needs a capability audit.
receipt_id as the real invoice ID.operation_reference and allow several records per receipt.reference_invoice_id for every operation.The initial implementation should serialize operations per invoice:
refund_cases row using a database transaction.Serializing active operations removes most ordering ambiguity while still satisfying the business requirement of repeated sequential partial refunds. Parallel operations can be reconsidered only if there is a demonstrated need.
For message delivery, use transactional outbox and idempotent inbox/consumer handling. At-least-once delivery and webhook redelivery are expected; “exactly once” is achieved through stable operation identity and deduplication, not by assuming the transport delivers once.
Credit notes fit naturally into the case/operation model:
invoice H_123
operation UUID-A -> CN_1
operation UUID-B -> CN_2
operation UUID-C -> CN_3, CN_4
All credit notes reference H_123. The operation-credit-note table records ownership. Existing refund_steps_changelog, already scoped by refund_id, can keep retrying CN creation/refund independently for each operation.
Before each new operation, hRefunds must reconcile:
Disagreement between these sources should fail closed and enter an explicit reconciliation state instead of guessing the remaining amount.
refund_cases.refunds without changing current reads.1 for existing invoice-backed refunds.refund_credit_notes and persist new CN mappings.getRefundOperationsByInvoiceId().At minimum, cover:
Run a three-to-five-day architecture spike with hRefunds, hPayments, and CRM owners. The output should be:
Do not remove the current invoice-level guards until the operation reference is carried end to end and duplicate/out-of-order completion events can be resolved deterministically.
comments (0)