diff --git a/lib/commerce/conformance.sh b/lib/commerce/conformance.sh index 860d256a..5ab42f86 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) +SUITES=(catalog cart price api promo stack quote ledger order) OUT_JSON="lib/commerce/scoreboard.json" OUT_MD="lib/commerce/scoreboard.md" @@ -48,6 +48,17 @@ run_suite() { (load "lib/persist/log.sx") (load "lib/persist/kv.sx") (load "lib/persist/idempotency.sx") +(load "lib/guest/lex.sx") +(load "lib/guest/reflective/env.sx") +(load "lib/guest/reflective/quoting.sx") +(load "lib/scheme/parser.sx") +(load "lib/scheme/eval.sx") +(load "lib/scheme/runtime.sx") +(load "lib/flow/spec.sx") +(load "lib/flow/store.sx") +(load "lib/flow/remote.sx") +(load "lib/flow/host.sx") +(load "lib/flow/api.sx") (load "lib/commerce/catalog.sx") (load "lib/commerce/cart.sx") (load "lib/commerce/price.sx") @@ -56,6 +67,7 @@ run_suite() { (load "lib/commerce/stack.sx") (load "lib/commerce/quote.sx") (load "lib/commerce/ledger.sx") +(load "lib/commerce/order.sx") (epoch 2) (eval "(define ct-pass 0)") (eval "(define ct-fail 0)") @@ -69,7 +81,7 @@ run_suite() { EPOCHS local OUTPUT - OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null) + OUTPUT=$(timeout 560 "$SX_SERVER" < "$TMP" 2>/dev/null) rm -f "$TMP" # The (list ct-pass ct-fail) result follows its (ok-len 2 N) ack line. diff --git a/lib/commerce/order.sx b/lib/commerce/order.sx new file mode 100644 index 00000000..2702f353 --- /dev/null +++ b/lib/commerce/order.sx @@ -0,0 +1,119 @@ +;; lib/commerce/order.sx — order lifecycle as a durable flow-on-sx flow. +;; +;; The lifecycle (reserve -> await payment -> fulfil) is a Scheme flow running +;; in the flow-on-sx guest (lib/flow). The flow is PURE ORCHESTRATION: it +;; carries only the order-id and enforces step ordering + the suspension at the +;; payment IO boundary. All IO/state lives in SX: the SX driver here services +;; each flow request by appending to the persist ledger (ledger.sx). +;; +;; reserve -> SX appends :reserved, resumes (synchronous host effect) +;; payment -> flow stays SUSPENDED until the SumUp webhook resumes it +;; fulfil -> SX appends :fulfilled, resumes (synchronous host effect) +;; +;; Durability: the flow's replay log is plain data (flow-store-export), so a +;; suspended order survives a process restart — order-flow-restart! simulates +;; that entirely Scheme-side. Idempotency: order-settle! only resumes a flow +;; still waiting on payment, so a replayed webhook is a no-op at the flow level, +;; and order-pay is idempotent at the ledger level. + +;; The flow definition (Scheme source). oid is in scope throughout the begin. +(define + order-flow-src + "(defflow order-lifecycle (lambda (oid) (begin (request (quote reserve) oid) (request (quote payment) oid) (request (quote fulfil) oid))))") + +;; Build a flow env with the order flow registered. Never returns the env from +;; an eval boundary (the env is large/cyclic — serializing it hangs). +(define + order-make-env + (fn + () + (let + ((env (flow-make-env))) + (begin (flow-run-in env order-flow-src) env)))) + +;; --- thin Scheme bridge (string-interpolated flow ops) --- + +(define + order-flow-start + (fn + (env oid) + (flow-run-in env (str "(flow/start order-lifecycle \"" oid "\")")))) + +(define + order-flow-resume + (fn + (env id sym) + (flow-run-in env (str "(flow/resume " id " (quote " sym "))")))) + +(define + order-flow-status + (fn (env id) (flow-run-in env (str "(flow/status " id ")")))) +(define + order-flow-result + (fn (env id) (flow-run-in env (str "(flow/result " id ")")))) + +;; The request kind the flow with this id is waiting on, or nil if it is not +;; suspended on a host request (done / cancelled / unknown). +(define + order-flow-waiting + (fn + (env id) + (let + ((reqs (flow-run-in env "(flow-host-requests)"))) + (let + ((mine (filter (fn (r) (= (first r) id)) reqs))) + (if (empty? mine) nil (nth (first mine) 1)))))) + +;; Id out of a (flow-suspended id tag) start/resume result. +(define order-susp-id (fn (susp) (nth susp 1))) + +;; --- high-level lifecycle (flow + ledger composed) --- + +;; Create the order, start the flow, service the reserve step, and leave the +;; flow suspended at payment. Returns the flow id (needed to settle later). +(define + order-begin! + (fn + (env b oid at quote) + (begin + (order-create b oid at quote) + (let + ((id (order-susp-id (order-flow-start env oid)))) + (begin + (order-reserve b oid (+ at 1) {}) + (order-flow-resume env id :reserved) + id))))) + +;; Settle a payment: record it, resume the flow past payment, service fulfil. +;; Idempotent — only acts when the flow is still waiting on payment, so a +;; replayed webhook returns :already-settled without double-charging. +(define + order-settle! + (fn + (env b id oid ref at amount) + (if + (= (order-flow-waiting env id) "payment") + (begin + (order-pay b oid ref at amount) + (order-flow-resume env id :paid) + (order-fulfil b oid (+ at 1) {}) + (order-flow-resume env id :fulfilled) + :settled) + :already-settled))) + +;; Simulate a process restart: export the flow store, reset the runtime, reload +;; the flow definition, reimport the store. Done entirely Scheme-side so the +;; (large) store is never marshalled across the boundary. The persist ledger is +;; a separate store and is unaffected. Suspended flows resume afterwards. +(define + order-flow-restart! + (fn + (env) + (flow-run-in + env + (str + "(begin (define _saved (flow-store-export)) " + flow-reset-src + " " + order-flow-src + " (flow-store-import! _saved) #t)")))) diff --git a/lib/commerce/scoreboard.json b/lib/commerce/scoreboard.json index b626e72b..71796a6b 100644 --- a/lib/commerce/scoreboard.json +++ b/lib/commerce/scoreboard.json @@ -7,9 +7,10 @@ "promo": {"pass": 17, "fail": 0}, "stack": {"pass": 16, "fail": 0}, "quote": {"pass": 13, "fail": 0}, - "ledger": {"pass": 20, "fail": 0} + "ledger": {"pass": 20, "fail": 0}, + "order": {"pass": 21, "fail": 0} }, - "total_pass": 132, + "total_pass": 153, "total_fail": 0, - "total": 132 + "total": 153 } diff --git a/lib/commerce/scoreboard.md b/lib/commerce/scoreboard.md index bd85a6f8..407ad0ac 100644 --- a/lib/commerce/scoreboard.md +++ b/lib/commerce/scoreboard.md @@ -12,4 +12,5 @@ _Generated by `lib/commerce/conformance.sh`_ | stack | 16 | 0 | 16 | | quote | 13 | 0 | 13 | | ledger | 20 | 0 | 20 | -| **Total** | **132** | **0** | **132** | +| order | 21 | 0 | 21 | +| **Total** | **153** | **0** | **153** | diff --git a/lib/commerce/tests/order.sx b/lib/commerce/tests/order.sx new file mode 100644 index 00000000..79b1bb7f --- /dev/null +++ b/lib/commerce/tests/order.sx @@ -0,0 +1,67 @@ +;; lib/commerce/tests/order.sx — order 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 (order-make-env)) +(define b (persist/mem-backend)) +(define q1 {:codes (list) :subtotal 1000 :discount 0 :total 1200 :tax 200}) + +;; --- happy path: begin suspends at payment --- + +(define id1 (order-begin! env b "O1" 100 q1)) + +(commerce-test "begin-status-reserved" (order-status b "O1") :reserved) +(commerce-test "begin-waiting-payment" (order-flow-waiting env id1) "payment") +(commerce-test "begin-not-yet-paid" (order-paid b "O1") 0) + +;; --- settle: payment webhook drives fulfilment --- + +(define s1 (order-settle! env b id1 "O1" "ref-1" 102 1200)) + +(commerce-test "settle-result" s1 :settled) +(commerce-test "settle-status-fulfilled" (order-status b "O1") :fulfilled) +(commerce-test "settle-flow-done" (order-flow-status env id1) "done") +(commerce-test "settle-recon-ok" (order-recon b "O1") :ok) +(commerce-test "settle-event-count" (len (order-events b "O1")) 4) + +;; --- webhook replay: a second settle is a no-op --- + +(define s1b (order-settle! env b id1 "O1" "ref-1" 102 1200)) + +(commerce-test "replay-already-settled" s1b :already-settled) +(commerce-test + "replay-no-extra-events" + (len (order-events b "O1")) + 4) +(commerce-test "replay-recon-still-ok" (order-recon b "O1") :ok) + +;; --- a second order gets its own flow id and suspends independently --- + +(define id2 (order-begin! env b "O2" 200 q1)) + +(commerce-test "second-distinct-id" (not (= id1 id2)) true) +(commerce-test + "second-waiting-payment" + (order-flow-waiting env id2) + "payment") +(commerce-test "first-unaffected" (order-status b "O1") :fulfilled) + +;; --- durability: a suspended order survives a process restart --- + +(define id3 (order-begin! env b "O3" 300 q1)) +(commerce-test "pre-restart-waiting" (order-flow-waiting env id3) "payment") + +(define _restart (order-flow-restart! env)) + +(commerce-test + "post-restart-still-waiting" + (order-flow-waiting env id3) + "payment") +(commerce-test "post-restart-ledger-intact" (order-status b "O3") :reserved) + +(define s3 (order-settle! env b id3 "O3" "ref-3" 302 1200)) + +(commerce-test "post-restart-settled" s3 :settled) +(commerce-test "post-restart-status" (order-status b "O3") :fulfilled) +(commerce-test "post-restart-recon-ok" (order-recon b "O3") :ok) +(commerce-test "post-restart-flow-done" (order-flow-status env id3) "done") diff --git a/plans/commerce-on-sx.md b/plans/commerce-on-sx.md index 12de8e99..0a2c96c3 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` → **132/132** (8 suites: catalog, cart, price, api, promo, stack, quote, ledger) — Phases 1-2 done; Phase 3 ledger done +`bash lib/commerce/conformance.sh` → **153/153** (9 suites: catalog, cart, price, api, promo, stack, quote, ledger, order) — Phases 1-3 done ## Ground rules @@ -66,8 +66,8 @@ lib/commerce/api.sx ── (commerce/add) (commerce/total) (commerce/checkout) - [x] tests: stacking order, mutually-exclusive promos, member vs guest ## Phase 3 — Order lifecycle (flow + store) -- [ ] order flow: reserve stock → await payment → fulfil -- [ ] payment webhook resumes the suspended flow +- [x] order flow: reserve stock → await payment → fulfil +- [x] payment webhook resumes the suspended flow - [x] order ledger as a `persist` stream; idempotent reconciliation ## Phase 4 — Reconciliation + federation @@ -76,6 +76,21 @@ lib/commerce/api.sx ── (commerce/add) (commerce/total) (commerce/checkout) - [ ] tests: webhook replay, partial refund, double-charge guard ## Progress log +- 2026-06-07 — `order.sx` (**Phase 3 complete**, checkboxes 1-2): order lifecycle + as a flow-on-sx flow `(lambda (oid) (begin (request 'reserve oid) (request + 'payment oid) (request 'fulfil oid)))` — pure orchestration carrying only the + order-id; the SX driver services each request by appending to the persist + ledger. `order-begin!` creates+reserves and leaves the flow SUSPENDED at + payment; `order-settle!` (the webhook) resumes → fulfils, and is idempotent + (only acts while waiting on payment, so a replayed webhook → :already-settled). + `order-flow-restart!` simulates a process restart entirely Scheme-side + (export→reset→reload→import) and the suspended order resumes correctly + afterwards with the persist ledger intact. Composes all three substrates + (minikanren pricing → flow lifecycle → persist ledger). order suite 21/21; + total 153/153. Gotchas: flow ids start at 1; never return flow-make-env across + the eval boundary (serializer hangs on the cyclic env); guest Scheme rejects + `:ok` keyword as a value — use `#t`. Flow env build ~150s CPU; order suite runs + single-process with timeout 560. - 2026-06-07 — `ledger.sx` (Phase 3 piece, checkbox 3): order ledger as a persist event stream "order/". Status/total/paid/recon are projections (folds) over events — ledger is the single source of truth. `order-pay`/