commerce: refund lifecycle as a flow-on-sx flow (20 tests) — Phase 5 backlog complete
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 39s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:01:16 +00:00
parent da349b169e
commit e66fbfc540
6 changed files with 198 additions and 9 deletions

View File

@@ -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")

97
lib/commerce/refund.sx Normal file
View File

@@ -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)))

View File

@@ -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
}

View File

@@ -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** |

View File

@@ -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)

View File

@@ -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`