# Multiple Partial Refunds Architecture Proposal

**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

## Summary

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.

## Goals

- 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.

## Domain terminology and invariants

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

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 state

### Current flow

```mermaid
sequenceDiagram
    participant Client
    participant HR as hRefunds
    participant CRM
    participant HP as hPayments
    participant CB as Chargebee

    Client->>HR: Refund invoice H_123 (selected items)
    HR->>CRM: Create request, receipt_id = H_123
    HR->>HR: updateOrCreate refund by identifier H_123
    HR->>HP: Refund original payment token
    HP-->>HR: REFUND_COMPLETED with payment/invoice context
    HR->>CB: Create credit note, reference_invoice_id = H_123
    HR->>CRM: Update status by receipt H_123
    HR->>HR: Mark the invoice refund completed

    Note over Client,CB: A second operation for H_123 has no independent identity.
```

### 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

```mermaid
erDiagram
    REFUNDS ||--o{ REFUND_ITEMS : contains
    REFUNDS ||--o{ REFUND_SUBSCRIPTIONS : contains
    REFUNDS ||--o{ REFUND_PAYMENTS : contains
    REFUNDS ||--o{ REFUND_STEPS_CHANGELOG : retries

    REFUNDS {
        bigint id PK
        string identifier "normally invoice ID"
        string customer_id
        bigint amount
        string currency
        string status
        string logic_type
    }

    REFUND_ITEMS {
        bigint refund_id FK
        string line_item_id
    }

    REFUND_PAYMENTS {
        bigint refund_id FK
        string payment_token
        string chargebee_transaction_id
        string provider_transaction_identifier
        bigint amount
        string refund_status
    }
```

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.

## Industry pattern

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:

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

References:

- <https://docs.stripe.com/api/refunds>
- <https://docs.stripe.com/api/idempotent_requests>
- <https://docs.adyen.com/api-explorer/Checkout/46/post/payments/_paymentPspReference_/refunds>
- <https://docs.adyen.com/unified-commerce/referenced-refunds/>
- <https://developer.paypal.com/api/payments/v2/captures-refund>
- <https://apidocs.chargebee.com/docs/api/credit_notes/create-credit-note>
- <https://learn.microsoft.com/en-us/azure/architecture/patterns/saga>
- <https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html>

## Options considered

### 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 architecture

### Target relationships

```mermaid
erDiagram
    REFUND_CASES ||--o{ REFUND_OPERATIONS : groups
    REFUND_OPERATIONS ||--o{ REFUND_OPERATION_ITEMS : selects
    REFUND_OPERATIONS ||--o{ REFUND_OPERATION_SUBSCRIPTIONS : affects
    REFUND_OPERATIONS ||--o{ REFUND_PAYMENT_ATTEMPTS : refunds
    REFUND_OPERATIONS ||--o{ REFUND_CREDIT_NOTES : creates
    REFUND_OPERATIONS ||--o{ REFUND_OPERATION_STEPS : executes
    REFUND_OPERATIONS ||--o{ OUTBOX_MESSAGES : publishes
    REFUND_PAYMENT_ATTEMPTS ||--o{ INBOX_EVENTS : receives

    REFUND_CASES {
        bigint id PK
        string invoice_id UK
        string customer_id
        string currency
        bigint original_refundable_amount
        bigint version
        timestamp created_at
        timestamp updated_at
    }

    REFUND_OPERATIONS {
        bigint id PK
        uuid operation_uuid UK
        bigint refund_case_id FK
        int sequence
        string idempotency_key UK
        bigint amount
        string status
        string logic_type
        string destination
        string reason
        text client_note
        bigint requested_by_user_id
        timestamp created_at
        timestamp updated_at
    }

    REFUND_OPERATION_ITEMS {
        bigint operation_id FK
        string line_item_id
        string subscription_id
        bigint amount
        string allocation_status
    }

    REFUND_PAYMENT_ATTEMPTS {
        bigint id PK
        bigint operation_id FK
        string payment_token
        string chargebee_transaction_id
        string provider_transaction_id
        string provider_refund_id
        string operation_reference
        bigint amount
        string status
    }

    REFUND_CREDIT_NOTES {
        bigint id PK
        bigint operation_id FK
        string credit_note_id UK
        string chargebee_transaction_id
        bigint amount
        string status
    }

    REFUND_OPERATION_STEPS {
        bigint operation_id FK
        string step
        string identifier
        boolean finished
        text payload
    }
```

### Practical mapping onto existing tables

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`.
- 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

```mermaid
stateDiagram-v2
    [*] --> Requested
    Requested --> Reserved: lock case and reserve items/amount
    Reserved --> PSPSubmitted: idempotent hPayments request
    Reserved --> Rejected: validation or permanent pre-PSP failure
    PSPSubmitted --> PSPConfirmed: correlated completion event
    PSPSubmitted --> Failed: confirmed provider failure
    PSPConfirmed --> CreditNoteCreated: create/record Chargebee CN
    CreditNoteCreated --> CRMSynchronized: update operation projection
    CRMSynchronized --> Completed

    Rejected --> [*]
    Failed --> [*]
    Completed --> [*]

    note right of PSPConfirmed
        Financial point of no return.
        Later steps are retryable,
        not compensated by a new charge.
    end note
```

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

```mermaid
sequenceDiagram
    participant Client
    participant HR as hRefunds Orchestrator
    participant DB as hRefunds DB
    participant CRM
    participant HP as hPayments
    participant PSP
    participant CB as Chargebee

    Client->>HR: Create partial refund for invoice H_123
    HR->>DB: Lock refund case H_123
    HR->>DB: Validate remaining amount/items
    HR->>DB: Create operation UUID-B + reservations + outbox
    HR-->>Client: Operation UUID-B accepted

    HR->>CRM: Upsert projection(operation UUID-B, invoice H_123)
    HR->>HP: Refund(operation_reference UUID-B, idempotency UUID-B)
    HP->>PSP: Provider refund with stable idempotency
    PSP-->>HP: Provider refund ID
    HP-->>HR: REFUND_COMPLETED(operation UUID-B, provider refund ID)

    HR->>DB: Deduplicate event and mark PSP confirmed
    HR->>CB: Create credit note(reference_invoice_id H_123)
    CB-->>HR: credit_note_id CN_456
    HR->>DB: Link UUID-B to CN_456
    HR->>CRM: Mark operation UUID-B completed
    HR->>DB: Mark UUID-B completed
```

## Service contract changes

### 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.”

## Concurrency and consistency

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 note behavior

Credit notes fit naturally into the case/operation model:

```text
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.

## Migration strategy

### 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.

## Testing and acceptance matrix

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.

## Open decisions for the team

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?

## Recommended next step

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.
