From e66fbfc54078e7b6b47c5958b03cfc064b9a489c Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 13:01:16 +0000 Subject: [PATCH] =?UTF-8?q?commerce:=20refund=20lifecycle=20as=20a=20flow-?= =?UTF-8?q?on-sx=20flow=20(20=20tests)=20=E2=80=94=20Phase=205=20backlog?= =?UTF-8?q?=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refund.sx — refund as a second flow-on-sx flow (request -> approve -> settle) with two suspension points (approval = human/policy decision, settle = provider). refund-begin! records :refund-requested and suspends at approval; refund-approve! advances to settle; refund-settle! records :refunded (idempotent) and completes; refund-reject! records :refund-rejected and cancels. Only :refunded moves the books. Reuses order.sx flow helpers. Completes the Phase 5 backlog. Total 278/278 across 17 suites. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/commerce/conformance.sh | 3 +- lib/commerce/refund.sx | 97 ++++++++++++++++++++++++++++++++++++ lib/commerce/scoreboard.json | 7 +-- lib/commerce/scoreboard.md | 3 +- lib/commerce/tests/refund.sx | 78 +++++++++++++++++++++++++++++ plans/commerce-on-sx.md | 19 +++++-- 6 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 lib/commerce/refund.sx create mode 100644 lib/commerce/tests/refund.sx diff --git a/lib/commerce/conformance.sh b/lib/commerce/conformance.sh index d5003ad7..a88fc390 100755 --- a/lib/commerce/conformance.sh +++ b/lib/commerce/conformance.sh @@ -17,7 +17,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(catalog cart price api promo stack quote ledger order recon federation attribution payment window nettax stock) +SUITES=(catalog cart price api promo stack quote ledger order recon federation attribution payment window nettax stock refund) OUT_JSON="lib/commerce/scoreboard.json" OUT_MD="lib/commerce/scoreboard.md" @@ -71,6 +71,7 @@ run_suite() { (load "lib/commerce/stock.sx") (load "lib/commerce/ledger.sx") (load "lib/commerce/order.sx") +(load "lib/commerce/refund.sx") (load "lib/commerce/payment.sx") (load "lib/commerce/recon.sx") (load "lib/commerce/federation.sx") diff --git a/lib/commerce/refund.sx b/lib/commerce/refund.sx new file mode 100644 index 00000000..f5ada963 --- /dev/null +++ b/lib/commerce/refund.sx @@ -0,0 +1,97 @@ +;; lib/commerce/refund.sx — refund lifecycle as a second flow-on-sx flow. +;; +;; A refund is request → approve → settle, with TWO genuine suspension points: +;; approval (a human/policy decision) and settlement (the provider issuing the +;; refund). Like order.sx the flow is pure orchestration carrying only the +;; order-id; the SX driver does all ledger IO and reuses order.sx's generic flow +;; helpers (order-flow-waiting/-resume/-status, order-susp-id). +;; +;; refund-begin! → ledger :refund-requested, flow suspends at 'approve +;; refund-approve! → resume past approval, flow suspends at 'settle +;; refund-settle! → ledger :refunded (idempotent), flow completes +;; refund-reject! → ledger :refund-rejected, flow cancelled +;; +;; Only :refunded moves the books (recon.sx), so a requested-but-unsettled or +;; rejected refund leaves reconciliation unchanged. + +(define + refund-flow-src + "(defflow refund-lifecycle (lambda (oid) (begin (request (quote approve) oid) (request (quote settle) oid))))") + +(define + refund-make-env + (fn + () + (let + ((env (flow-make-env))) + (begin (flow-run-in env refund-flow-src) env)))) + +;; Register the refund flow into an existing (e.g. order) env. +(define + refund-flow-load! + (fn (env) (begin (flow-run-in env refund-flow-src) env))) + +(define + refund-flow-start + (fn + (env oid) + (flow-run-in env (str "(flow/start refund-lifecycle \"" oid "\")")))) + +;; --- ledger writes --- + +(define + refund-request + (fn + (b oid ref at amount) + (persist/append-once + b + (order-stream oid) + (str "refund-req/" ref) + :refund-requested at + {:amount amount :ref ref}))) + +;; --- lifecycle --- + +;; Open a refund: record the request, start the flow, suspend at approval. +(define + refund-begin! + (fn + (env b oid ref at amount) + (begin + (refund-request b oid ref at amount) + (order-susp-id (refund-flow-start env oid))))) + +(define + refund-approve! + (fn + (env id) + (if + (= (order-flow-waiting env id) "approve") + (begin (order-flow-resume env id :approved) :approved) + :not-pending-approval))) + +(define + refund-reject! + (fn + (env b oid id at reason) + (if + (= (order-flow-waiting env id) "approve") + (begin + (persist/append b (order-stream oid) :refund-rejected at {:reason reason}) + (flow-run-in env (str "(flow/cancel " id ")")) + :rejected) + :not-pending-approval))) + +;; Settle (provider issued the refund): idempotent — only acts while waiting on +;; settle, so a replayed provider callback returns :already-settled. +(define + refund-settle! + (fn + (env b id oid ref at amount) + (if + (= (order-flow-waiting env id) "settle") + (begin + (order-refund b oid ref at amount) + (order-flow-resume env id :settled) + :settled) + :already-settled))) diff --git a/lib/commerce/scoreboard.json b/lib/commerce/scoreboard.json index 99df57cf..e5ba2339 100644 --- a/lib/commerce/scoreboard.json +++ b/lib/commerce/scoreboard.json @@ -15,9 +15,10 @@ "payment": {"pass": 7, "fail": 0}, "window": {"pass": 19, "fail": 0}, "nettax": {"pass": 11, "fail": 0}, - "stock": {"pass": 19, "fail": 0} + "stock": {"pass": 19, "fail": 0}, + "refund": {"pass": 20, "fail": 0} }, - "total_pass": 258, + "total_pass": 278, "total_fail": 0, - "total": 258 + "total": 278 } diff --git a/lib/commerce/scoreboard.md b/lib/commerce/scoreboard.md index 9c1202a4..4e90d445 100644 --- a/lib/commerce/scoreboard.md +++ b/lib/commerce/scoreboard.md @@ -20,4 +20,5 @@ _Generated by `lib/commerce/conformance.sh`_ | window | 19 | 0 | 19 | | nettax | 11 | 0 | 11 | | stock | 19 | 0 | 19 | -| **Total** | **258** | **0** | **258** | +| refund | 20 | 0 | 20 | +| **Total** | **278** | **0** | **278** | diff --git a/lib/commerce/tests/refund.sx b/lib/commerce/tests/refund.sx new file mode 100644 index 00000000..c833824a --- /dev/null +++ b/lib/commerce/tests/refund.sx @@ -0,0 +1,78 @@ +;; lib/commerce/tests/refund.sx — refund lifecycle as a flow-on-sx flow. +;; Uses (commerce-test name got expected) provided by conformance.sh. +;; Builds the (expensive) flow env once; all assertions share it. + +(define env (refund-make-env)) +(define b (persist/mem-backend)) +(define q1 {:codes (list) :subtotal 1000 :discount 0 :total 1200 :tax 200}) + +;; a paid, fulfilled order to refund (set up directly via the ledger) +(define _c (order-create b "O1" 1 q1)) +(define _p (order-pay b "O1" "pay-1" 2 1200)) +(commerce-test "setup-recon-ok" (order-recon b "O1") :ok) + +;; --- happy path: request -> approve -> settle --- + +(define rid (refund-begin! env b "O1" "rf-1" 10 500)) + +(commerce-test "begin-waiting-approve" (order-flow-waiting env rid) "approve") +(commerce-test + "begin-not-yet-refunded" + (order-refunded-amount-of (order-events b "O1")) + 0) +(commerce-test "begin-recon-unchanged" (order-recon b "O1") :ok) + +(define a1 (refund-approve! env rid)) +(commerce-test "approve-result" a1 :approved) +(commerce-test "approve-waiting-settle" (order-flow-waiting env rid) "settle") + +(define s1 (refund-settle! env b rid "O1" "rf-1" 11 500)) +(commerce-test "settle-result" s1 :settled) +(commerce-test "settle-flow-done" (order-flow-status env rid) "done") +(commerce-test + "settle-refunded-amount" + (order-refunded-amount-of (order-events b "O1")) + 500) +;; net 1200 - 500 = 700 < total 1200 -> underpaid (partial refund) +(commerce-test "settle-recon-underpaid" (order-recon b "O1") :underpaid) + +;; --- idempotent settle: replayed provider callback is a no-op --- + +(define s1b (refund-settle! env b rid "O1" "rf-1" 11 500)) +(commerce-test "replay-already-settled" s1b :already-settled) +(commerce-test + "replay-refunded-once" + (order-refunded-amount-of (order-events b "O1")) + 500) + +;; --- reject path: approval denied, books untouched --- + +(define _c2 (order-create b "O2" 1 q1)) +(define _p2 (order-pay b "O2" "pay-2" 2 1200)) + +(define rid2 (refund-begin! env b "O2" "rf-2" 20 1200)) +(commerce-test + "reject-waiting-approve" + (order-flow-waiting env rid2) + "approve") + +(define j2 (refund-reject! env b "O2" rid2 21 "policy")) +(commerce-test "reject-result" j2 :rejected) +(commerce-test "reject-flow-not-waiting" (order-flow-waiting env rid2) nil) +(commerce-test + "reject-no-refund" + (order-refunded-amount-of (order-events b "O2")) + 0) +(commerce-test "reject-recon-ok" (order-recon b "O2") :ok) + +;; settling a rejected/cancelled refund does nothing +(define s2 (refund-settle! env b rid2 "O2" "rf-2" 22 1200)) +(commerce-test "reject-then-settle-noop" s2 :already-settled) +(commerce-test + "reject-still-no-refund" + (order-refunded-amount-of (order-events b "O2")) + 0) + +;; --- distinct flow ids --- + +(commerce-test "distinct-refund-ids" (not (= rid rid2)) true) diff --git a/plans/commerce-on-sx.md b/plans/commerce-on-sx.md index 9f07d849..d6b6e1cb 100644 --- a/plans/commerce-on-sx.md +++ b/plans/commerce-on-sx.md @@ -21,7 +21,7 @@ reconciliation — all auditable via the event log. ## Status (rolling) -`bash lib/commerce/conformance.sh` → **258/258** (16 suites; + stock) — **roadmap + full Phase 5 backlog complete** +`bash lib/commerce/conformance.sh` → **278/278** (17 suites; + refund) — **roadmap + full Phase 5 backlog complete** ## Ground rules @@ -75,7 +75,7 @@ lib/commerce/api.sx ── (commerce/add) (commerce/total) (commerce/checkout) - [x] cross-instance catalog (federated marketplace) — out-of-scope stub - [x] tests: webhook replay, partial refund, double-charge guard -## Phase 5 — Extensions (backlog; base roadmap complete) +## Phase 5 — Extensions (backlog; base roadmap complete) — **ALL DONE (278/278)** Thesis-aligned deepenings of the relational/composition showcase. Pick the one that unlocks the most tests per effort each iteration. - [x] line-level discount attribution — "which line item triggered this discount?" @@ -87,8 +87,9 @@ that unlocks the most tests per effort each iteration. - [x] discount-aware tax policy — `nettax.sx`: `cart-quote-net` taxes the net (post-discount) base; `allocate-discount` spreads the basket discount across lines by extended share with largest-remainder so per-line shares sum exactly. -- [ ] refund as a flow — refund lifecycle (request → approve → settle) as a second - flow-on-sx flow, recorded in the ledger; idempotent. +- [x] refund as a flow — `refund.sx`: refund lifecycle (request → approve → + settle) as a second flow-on-sx flow with two suspension points; idempotent + settle, reject path, ledger-recorded; reuses order.sx flow helpers. - [x] stock-constrained reservation — `stock.sx`: `can-reserve?`/`reserve-check` precondition (host gates order-begin! on it, keeping the flow pure); `reservation-shortfalls` detail; `effective-available` nets out concurrent @@ -100,6 +101,16 @@ that unlocks the most tests per effort each iteration. agnostic; `order-settle!(ref, amount)` is the resume seam. ## Progress log +- 2026-06-07 — `refund.sx` (**Phase 5 backlog complete**): refund lifecycle as a + second flow-on-sx flow `(lambda (oid) (begin (request 'approve oid) (request + 'settle oid)))` — two suspension points (approval = human/policy decision, + settle = provider). `refund-begin!` records :refund-requested and suspends at + approval; `refund-approve!` advances to settle; `refund-settle!` records + :refunded (idempotent) and completes; `refund-reject!` records :refund-rejected + and cancels the flow. Only :refunded moves the books, so requested/rejected + refunds leave recon unchanged. Reuses order.sx flow helpers. refund suite + 20/20; total 278/278 (17 suites). NB: conformance now has two env-building + suites (order, refund) — each builds the ~150s flow env in its own process. - 2026-06-07 — `stock.sx` (Phase 5 ext): stock-constrained reservation. Design choice: reservation is a precondition the host checks BEFORE order-begin! (validate → begin), keeping the order flow pure orchestration. `available-stock`