Multiple Partial Refunds Architecture Proposal

publicv1
2h ago
3 views0 comments0 reviews13 min read
raw .md ↗

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:

  • the refund case, identified by the original Chargebee invoice; and
  • a refund operation, identified by a stable UUID and representing one independently retryable partial refund.

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.

  • Allow several partial refunds of different items from the same invoice.
  • Never refund the same line item or payment amount twice.
  • Correlate asynchronous hPayments callbacks to exactly one refund operation.
  • Allow each operation to have its own status, retries, payments, credit notes, audit trail, and history row.
  • Preserve the real invoice ID for Chargebee, CRM invoice context, and existing customer-facing APIs.
  • Migrate additively and keep existing single-refund records readable.
TermMeaning
Refund caseThe aggregate for all refunds against one original invoice.
Refund operationOne requested refund with its own amount, selected items, lifecycle, and idempotency key.
Payment attemptOne operation's refund against one original payment leg.
ReservationAmount and items temporarily claimed by a non-terminal operation.
External referenceA provider refund ID, CRM operation reference, or Chargebee credit note ID belonging to an operation.

Required invariants:

  1. Every refund operation has a globally unique, stable operation UUID.
  2. Retrying the same operation reuses its UUID; a new partial refund always gets a new UUID.
  3. The total of active reservations and completed refunds cannot exceed the refundable amount of the invoice or any payment leg.
  4. An invoice line item cannot belong to two active or completed operations.
  5. At most one non-terminal operation is allowed per refund case in the initial version.
  6. A PSP-confirmed refund is the financial point of no return. Chargebee, CRM, and local completion must retry until converged rather than attempting to charge the customer again.
  7. Chargebee credit notes always reference the real invoice ID, never a synthetic operation identifier.

#Current flow

#Existing assumptions

  • RefundsRepository::createFromDto() and createFromArray() use updateOrCreate() with the invoice-like identifier. A later operation would overwrite or reuse the first row.
  • Regular and manual handlers reject an invoice whenever CRM exposes any refund status.
  • Fraud eligibility and persistence explicitly skip an invoice when getRefundByInvoiceId() returns a row.
  • CRM rejects a second refund request for the same receipt_id and updates status by receipt.
  • hPayments completion webhooks contain original payment/invoice context but no stable hRefunds operation reference or provider refund ID.
  • RefundDataManager::getRefundByPaymentToken() deliberately returns no refund when a token maps to multiple refund rows.
  • Refund history looks up one hRefunds row and the first matching credit note by invoice ID.
  • refund_items uniqueness is scoped to (refund_id, line_item_id), not across operations for the invoice.

Relevant implementation anchors:

  • app/Repositories/RefundsRepository.php
  • app/Services/Managers/RefundDataManager/RefundDataManager.php
  • app/Services/RefundService/Handlers/InitiateAutomaticRefundHandler.php
  • app/Services/Fraud/FraudRefundService.php
  • app/Services/ExternalServicesWrapper/Services/HPayments/Webhooks/RefundedWebhookHandler.php
  • app/Services/PostWebhookHandlers/Handlers/RegularPostHandler.php
  • app/Services/History/HistoryBuilderService.php
  • CRM app/Modules/Hrefunds/Presentation/Http/Controllers/Api/RefundRequestsController.php
  • hPayments app/Service/Webhook/Contents/PaymentRefundWebhookContent.php

#Current logical data model

The 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:

  • Stripe creates multiple refund objects against one charge or PaymentIntent until the remaining amount is exhausted.
  • Adyen accepts multiple partial refunds against the original payment reference and returns a unique reference for each asynchronous refund request.
  • PayPal accepts multiple partial refunds against one capture and returns a distinct refund ID.
  • Chargebee permits refundable credit notes against the original invoice and calculates the remaining refundable amount after subtracting existing non-voided credit notes.

This leads to the same general model:

original payment or invoice
    -> refund operation 1
    -> refund operation 2
    -> refund operation 3

References:

#1. Suffix the invoice identifier for later refunds

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.

#2. Keep one mutable refund row per invoice

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.

#3. Full event sourcing

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.

#4. Refund case with child operations

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.

#Target relationships

#Practical mapping onto existing tables

The target semantics do not require immediately renaming large tables:

Target conceptInitial physical implementation
refund_casesNew table.
refund_operationsExisting refunds table plus refund_case_id, operation_uuid, sequence, idempotency_key, and destination.
refund_operation_itemsExisting refund_items; add an amount/allocation state only if item-level amounts must be reserved explicitly.
refund_operation_subscriptionsExisting refund_subscriptions.
refund_payment_attemptsExisting refund_payments; add provider_refund_id and operation_reference.
refund_credit_notesNew table, because one operation may create several credit notes.
refund_operation_stepsExisting refund_steps_changelog.
outbox/inboxNew 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.
  • Item overlap and amount limits must be checked while locking the refund case. MySQL does not provide a convenient conditional unique constraint for “active or completed allocations but not released allocations,” so the transaction and locking rule is part of the invariant.

#Operation lifecycle

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.

#Target interaction

#hRefunds API

  • Return operation_id when a refund is accepted.
  • Accept a client idempotency key where callers may safely retry creation.
  • History should expose a list of operations under an invoice, or individual rows carrying both invoice_id and operation_id.
  • Existing invoice-based endpoints may remain, but invoice lookup must no longer imply exactly one refund.

#hPayments

Required request fields:

  • operation_reference: stable hRefunds operation UUID.
  • existing original payment token and provider transaction identifier.
  • operation amount.

Required response/event fields:

  • echo operation_reference;
  • return and persist a provider refund ID when the gateway supplies one;
  • include operation_reference and provider refund ID in completion/failure webhooks;
  • treat a retry with the same operation reference and same parameters idempotently;
  • reject reuse of an operation reference with different parameters.

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.

#CRM

  • Preserve receipt_id as the real invoice ID.
  • Add operation_reference and allow several records per receipt.
  • Create/upsert and update status by operation reference, not by receipt alone.
  • Decide whether CRM remains a projection of hRefunds operation state or owns any manual workflow state. The recommendation is that hRefunds owns the operation lifecycle and CRM projects it.

#Chargebee

  • Continue sending the original invoice ID as reference_invoice_id for every operation.
  • Persist every returned credit note ID against the operation.
  • Calculate against remaining refundable value and exclude already credited line items.
  • Support one-to-many operation-to-credit-note mapping for multi-payment and consolidated flows.
  • Do not correlate operations by credit-note ordering or by “first credit note for invoice.”

The initial implementation should serialize operations per invoice:

  1. Lock the refund_cases row using a database transaction.
  2. Reject creation if another operation for the case is non-terminal.
  3. Re-read current operations, active reservations, and relevant Chargebee state.
  4. Validate selected line items and remaining amount.
  5. Persist the operation, reservations, and outbox message atomically.
  6. Release the lock before external calls.

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:

  • completed local operations;
  • active local reservations;
  • Chargebee non-voided issued credit notes;
  • provider-refunded amounts where the local saga is incomplete.

Disagreement between these sources should fail closed and enter an explicit reconciliation state instead of guessing the remaining amount.

#Phase 0: Contract and capability spike

  • Create a gateway matrix: repeated partial support, provider refund ID, idempotency, synchronous response, webhook correlation, and failure behavior.
  • Prove two same-amount partial refunds against one payment on one gateway.
  • Agree the operation state machine and ownership boundaries with CRM and hPayments teams.
  • Record the result in an ADR before implementation.

#Phase 1: Additive hRefunds model

  • Add refund_cases.
  • Add operation fields to refunds without changing current reads.
  • Backfill one case and sequence 1 for existing invoice-backed refunds.
  • Give every existing refund a generated operation UUID.
  • Add refund_credit_notes and persist new CN mappings.
  • Introduce plural repository APIs such as getRefundOperationsByInvoiceId().

#Phase 2: External correlation

  • Update CRM to store multiple operations per receipt and address status by operation UUID.
  • Update hPayments request, persistence, gateway adapters, and webhooks to carry the operation UUID end to end.
  • Add inbox deduplication and outbox delivery.

#Phase 3: Enable repeated partial refunds

  • Add case locking and reservation validation.
  • Remove invoice-level “any refund exists” guards only for eligible, fully correlated gateways and flows.
  • Rebuild history and observability around operations.
  • Enable behind flags by gateway and logic type.

#Phase 4: Expand and clean up

  • Extend to manual, exception, downgrade, balance, multi-payment, consolidated, and related-invoice flows after their invariants are tested.
  • Consider physical table renames only after all code uses operation terminology; table renaming is not required to deliver the feature.

At minimum, cover:

  • Two sequential partial refunds selecting different line items.
  • Two equal-amount partial refunds against the same payment token.
  • Attempt to select an already refunded or reserved item.
  • Attempt to exceed invoice or individual payment-leg remaining amount.
  • Duplicate API submission with the same idempotency key.
  • Reuse of an idempotency key with different parameters.
  • Concurrent creation requests for one invoice.
  • hPayments timeout before the caller knows whether submission succeeded.
  • Duplicate and out-of-order hPayments webhooks.
  • PSP success followed by Chargebee failure and retry.
  • Credit note created followed by CRM failure and retry.
  • Multiple payment legs and multiple credit notes in one operation.
  • Rejected operation releasing its reservation.
  • Fully refunded or voided invoice refusing further operations.
  • History displaying all operations and their credit notes.
  • Legacy single-refund records after backfill.
  1. Is one non-terminal operation per invoice acceptable for the first version?
  2. Are refunds always line-item atomic, or can part of one line item's quantity/amount be refunded in separate operations?
  3. Can later operations use a different destination, such as first PSP and later Hostinger Balance?
  4. Which service owns the canonical lifecycle: hRefunds or CRM?
  5. Which gateways can supply a provider refund ID and echo an operation reference today?
  6. What is the terminal behavior when the PSP refund succeeds but Chargebee can no longer create the expected credit note?
  7. How should externally initiated/manual PSP refunds be attached to a case and operation?
  8. Should a rejected operation's line-item reservation be reusable, and which statuses count as safely released?
  9. How should consolidated/related invoices define the case boundary: payment invoice only, or a wider invoice family?
  10. Do existing customer and support clients require the invoice ID to remain the displayed refund identifier?

Run a three-to-five-day architecture spike with hRefunds, hPayments, and CRM owners. The output should be:

  • an approved ADR selecting the case/operation model;
  • the gateway capability matrix;
  • the operation state machine and failure matrix;
  • request/webhook contract examples;
  • an additive migration sketch; and
  • a scoped walking-skeleton plan for one gateway and fraud or regular refunds.

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)

reviews (0)