@openlup/platform 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -231
- package/README.md +172 -6
- package/dist/composition/composeBundle.d.ts +6 -0
- package/dist/composition/composeBundle.js +2 -0
- package/dist/composition/index.js +1 -0
- package/dist/payments/adapterConformance.d.ts +8 -0
- package/dist/payments/adapterConformance.js +109 -0
- package/dist/payments/bindings.d.ts +3 -0
- package/dist/payments/bindings.js +17 -0
- package/dist/payments/checkoutActivePaymentActionResolver.d.ts +7 -0
- package/dist/payments/checkoutActivePaymentActionResolver.js +77 -0
- package/dist/payments/checkoutRecoveryPaymentResolver.d.ts +8 -0
- package/dist/payments/checkoutRecoveryPaymentResolver.js +87 -0
- package/dist/payments/contracts/adapter.d.ts +113 -0
- package/dist/payments/contracts/adapter.js +1 -0
- package/dist/payments/contracts/conformance.d.ts +155 -0
- package/dist/payments/contracts/conformance.js +1 -0
- package/dist/payments/contracts/evidence.d.ts +157 -0
- package/dist/payments/contracts/evidence.js +1 -0
- package/dist/payments/contracts/index.d.ts +10 -0
- package/dist/payments/contracts/index.js +1 -0
- package/dist/payments/contracts/preparedAttempt.d.ts +69 -0
- package/dist/payments/contracts/preparedAttempt.js +1 -0
- package/dist/payments/contracts/reconciliation.d.ts +303 -0
- package/dist/payments/contracts/reconciliation.js +1 -0
- package/dist/payments/contracts/repair.d.ts +92 -0
- package/dist/payments/contracts/repair.js +1 -0
- package/dist/payments/contracts/truth.d.ts +92 -0
- package/dist/payments/contracts/truth.js +1 -0
- package/dist/payments/eventConformanceRunner.d.ts +12 -0
- package/dist/payments/eventConformanceRunner.js +125 -0
- package/dist/payments/index.d.ts +16 -0
- package/dist/payments/index.js +16 -0
- package/dist/payments/paymentAttemptSilenceWindow.d.ts +10 -0
- package/dist/payments/paymentAttemptSilenceWindow.js +19 -0
- package/dist/payments/paymentMethodLifecycle.d.ts +11 -0
- package/dist/payments/paymentMethodLifecycle.js +37 -0
- package/dist/payments/paymentProviderReconciliationAbsenceReopen.d.ts +7 -0
- package/dist/payments/paymentProviderReconciliationAbsenceReopen.js +100 -0
- package/dist/payments/paymentProviderReconciliationEvidence.d.ts +36 -0
- package/dist/payments/paymentProviderReconciliationEvidence.js +91 -0
- package/dist/payments/paymentProviderReconciliationWorker.d.ts +4 -0
- package/dist/payments/paymentProviderReconciliationWorker.js +245 -0
- package/dist/payments/paymentTruth.d.ts +8 -0
- package/dist/payments/paymentTruth.js +125 -0
- package/dist/payments/paymentVerifyNowHandler.d.ts +27 -0
- package/dist/payments/paymentVerifyNowHandler.js +70 -0
- package/dist/payments/paymentVerifyNowService.d.ts +23 -0
- package/dist/payments/paymentVerifyNowService.js +93 -0
- package/dist/payments/paymentWebhookPostProcessing.d.ts +22 -0
- package/dist/payments/paymentWebhookPostProcessing.js +15 -0
- package/dist/payments/postgresPaymentTruth.d.ts +10 -0
- package/dist/payments/postgresPaymentTruth.js +126 -0
- package/dist/payments/preparedAttempt.d.ts +6 -0
- package/dist/payments/preparedAttempt.js +70 -0
- package/dist/payments/preparedAttemptFinalization.d.ts +5 -0
- package/dist/payments/preparedAttemptFinalization.js +12 -0
- package/dist/payments/schemaCapabilities.d.ts +10 -0
- package/dist/payments/schemaCapabilities.js +10 -0
- package/dist/payments/sql.d.ts +20 -0
- package/dist/payments/sql.js +6 -0
- package/dist/payments/stripe/classifyDecline.d.ts +46 -0
- package/dist/payments/stripe/classifyDecline.js +58 -0
- package/dist/payments/stripe/declineFailureHints.d.ts +46 -0
- package/dist/payments/stripe/declineFailureHints.js +87 -0
- package/dist/payments/stripe/donorContracts.d.ts +120 -0
- package/dist/payments/stripe/donorContracts.js +1 -0
- package/dist/payments/stripe/executionDecline.d.ts +53 -0
- package/dist/payments/stripe/executionDecline.js +107 -0
- package/dist/payments/stripe/index.d.ts +16 -0
- package/dist/payments/stripe/index.js +239 -0
- package/dist/payments/stripe/intentMapper.d.ts +26 -0
- package/dist/payments/stripe/intentMapper.js +51 -0
- package/dist/payments/stripe/methodFacts.d.ts +10 -0
- package/dist/payments/stripe/methodFacts.js +12 -0
- package/dist/payments/stripe/paymentFailureEvidence.d.ts +26 -0
- package/dist/payments/stripe/paymentFailureEvidence.js +33 -0
- package/dist/payments/stripe/stripeFailureEvidence.d.ts +41 -0
- package/dist/payments/stripe/stripeFailureEvidence.js +82 -0
- package/dist/payments/stripe/stripeIntentConfirmationEvidence.d.ts +28 -0
- package/dist/payments/stripe/stripeIntentConfirmationEvidence.js +32 -0
- package/dist/payments/stripe/stripePaymentReconciliationProvider.d.ts +35 -0
- package/dist/payments/stripe/stripePaymentReconciliationProvider.js +222 -0
- package/dist/payments/stripe/stripeRefundEvidence.d.ts +8 -0
- package/dist/payments/stripe/stripeRefundEvidence.js +17 -0
- package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.d.ts +45 -0
- package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.js +220 -0
- package/dist/payments/stripe/stripeWebhookNormalizer.d.ts +14 -0
- package/dist/payments/stripe/stripeWebhookNormalizer.js +243 -0
- package/dist/payments/stripe/webhookSignature.d.ts +14 -0
- package/dist/payments/stripe/webhookSignature.js +43 -0
- package/dist/payments/tpay/blikRecurringMapper.d.ts +37 -0
- package/dist/payments/tpay/blikRecurringMapper.js +24 -0
- package/dist/payments/tpay/capabilities.d.ts +3 -0
- package/dist/payments/tpay/capabilities.js +18 -0
- package/dist/payments/tpay/classifyDecline.d.ts +46 -0
- package/dist/payments/tpay/classifyDecline.js +58 -0
- package/dist/payments/tpay/declineFailureHints.d.ts +72 -0
- package/dist/payments/tpay/declineFailureHints.js +37 -0
- package/dist/payments/tpay/donorContracts.d.ts +140 -0
- package/dist/payments/tpay/donorContracts.js +1 -0
- package/dist/payments/tpay/index.d.ts +43 -0
- package/dist/payments/tpay/index.js +264 -0
- package/dist/payments/tpay/paymentFailureEvidence.d.ts +26 -0
- package/dist/payments/tpay/paymentFailureEvidence.js +33 -0
- package/dist/payments/tpay/preparedProviderAttempt.d.ts +22 -0
- package/dist/payments/tpay/preparedProviderAttempt.js +24 -0
- package/dist/payments/tpay/providerAttemptFailureDiagnostic.d.ts +3 -0
- package/dist/payments/tpay/providerAttemptFailureDiagnostic.js +95 -0
- package/dist/payments/tpay/tpayCreateTransactionInput.d.ts +25 -0
- package/dist/payments/tpay/tpayCreateTransactionInput.js +128 -0
- package/dist/payments/tpay/tpayDeclineEvidence.d.ts +126 -0
- package/dist/payments/tpay/tpayDeclineEvidence.js +177 -0
- package/dist/payments/tpay/tpayDispatchFailure.d.ts +60 -0
- package/dist/payments/tpay/tpayDispatchFailure.js +121 -0
- package/dist/payments/tpay/tpayFailureEvidence.d.ts +20 -0
- package/dist/payments/tpay/tpayFailureEvidence.js +61 -0
- package/dist/payments/tpay/tpayHttpClient.d.ts +52 -0
- package/dist/payments/tpay/tpayHttpClient.js +185 -0
- package/dist/payments/tpay/tpayHttpFailure.d.ts +57 -0
- package/dist/payments/tpay/tpayHttpFailure.js +143 -0
- package/dist/payments/tpay/tpayPaymentExecutionAdapter.d.ts +20 -0
- package/dist/payments/tpay/tpayPaymentExecutionAdapter.js +249 -0
- package/dist/payments/tpay/tpayPaymentReconciliationProvider.d.ts +5 -0
- package/dist/payments/tpay/tpayPaymentReconciliationProvider.js +244 -0
- package/dist/payments/tpay/tpayTransactionPayload.d.ts +120 -0
- package/dist/payments/tpay/tpayTransactionPayload.js +96 -0
- package/dist/payments/tpay/wallClockTime.d.ts +31 -0
- package/dist/payments/tpay/wallClockTime.js +57 -0
- package/dist/payments/tpay/webhookJwsVerifier.d.ts +17 -0
- package/dist/payments/tpay/webhookJwsVerifier.js +51 -0
- package/dist/payments/tpay/webhookNormalizer.d.ts +54 -0
- package/dist/payments/tpay/webhookNormalizer.js +200 -0
- package/dist/payments/verifiedEvidenceProjection.d.ts +12 -0
- package/dist/payments/verifiedEvidenceProjection.js +166 -0
- package/dist/payments/verifiedIngress.d.ts +119 -0
- package/dist/payments/verifiedIngress.js +220 -0
- package/package.json +22 -2
- package/release-gates.json +64 -3
- package/sql/20260810180000_payment_settlement_rail.sql +574 -0
- package/sql/20260815150000_payment_truth_reconciliation.sql +479 -0
- package/sql/20260826100000_late_payment_shifts_the_subscription_cycle.sql +215 -0
- package/src/composition/composeBundle.ts +9 -0
- package/src/composition/index.ts +1 -0
- package/src/payments/adapterConformance.ts +118 -0
- package/src/payments/bindings.ts +17 -0
- package/src/payments/checkoutActivePaymentActionResolver.ts +97 -0
- package/src/payments/checkoutRecoveryPaymentResolver.ts +108 -0
- package/src/payments/contracts/adapter.ts +96 -0
- package/src/payments/contracts/conformance.ts +171 -0
- package/src/payments/contracts/evidence.ts +142 -0
- package/src/payments/contracts/index.ts +54 -0
- package/src/payments/contracts/preparedAttempt.ts +86 -0
- package/src/payments/contracts/reconciliation.ts +296 -0
- package/src/payments/contracts/repair.ts +95 -0
- package/src/payments/contracts/truth.ts +108 -0
- package/src/payments/eventConformanceRunner.ts +149 -0
- package/src/payments/index.ts +21 -0
- package/src/payments/paymentAttemptSilenceWindow.ts +26 -0
- package/src/payments/paymentMethodLifecycle.ts +50 -0
- package/src/payments/paymentProviderReconciliationAbsenceReopen.ts +130 -0
- package/src/payments/paymentProviderReconciliationEvidence.ts +117 -0
- package/src/payments/paymentProviderReconciliationWorker.ts +291 -0
- package/src/payments/paymentTruth.ts +131 -0
- package/src/payments/paymentVerifyNowHandler.ts +94 -0
- package/src/payments/paymentVerifyNowService.ts +125 -0
- package/src/payments/paymentWebhookPostProcessing.ts +36 -0
- package/src/payments/postgresPaymentTruth.ts +149 -0
- package/src/payments/preparedAttempt.ts +90 -0
- package/src/payments/preparedAttemptFinalization.ts +13 -0
- package/src/payments/schemaCapabilities.ts +10 -0
- package/src/payments/sql.ts +6 -0
- package/src/payments/stripe/classifyDecline.ts +62 -0
- package/src/payments/stripe/declineFailureHints.ts +89 -0
- package/src/payments/stripe/donorContracts.ts +87 -0
- package/src/payments/stripe/executionDecline.ts +126 -0
- package/src/payments/stripe/index.ts +235 -0
- package/src/payments/stripe/intentMapper.ts +58 -0
- package/src/payments/stripe/methodFacts.ts +21 -0
- package/src/payments/stripe/paymentFailureEvidence.ts +58 -0
- package/src/payments/stripe/stripeFailureEvidence.ts +115 -0
- package/src/payments/stripe/stripeIntentConfirmationEvidence.ts +34 -0
- package/src/payments/stripe/stripePaymentReconciliationProvider.ts +263 -0
- package/src/payments/stripe/stripeRefundEvidence.ts +19 -0
- package/src/payments/stripe/stripeSandboxPaymentExecutionAdapter.ts +283 -0
- package/src/payments/stripe/stripeWebhookNormalizer.ts +277 -0
- package/src/payments/stripe/webhookSignature.ts +55 -0
- package/src/payments/tpay/blikRecurringMapper.ts +42 -0
- package/src/payments/tpay/capabilities.ts +21 -0
- package/src/payments/tpay/classifyDecline.ts +62 -0
- package/src/payments/tpay/declineFailureHints.ts +90 -0
- package/src/payments/tpay/donorContracts.ts +104 -0
- package/src/payments/tpay/index.ts +257 -0
- package/src/payments/tpay/paymentFailureEvidence.ts +58 -0
- package/src/payments/tpay/preparedProviderAttempt.ts +38 -0
- package/src/payments/tpay/providerAttemptFailureDiagnostic.ts +99 -0
- package/src/payments/tpay/tpayCreateTransactionInput.ts +158 -0
- package/src/payments/tpay/tpayDeclineEvidence.ts +233 -0
- package/src/payments/tpay/tpayDispatchFailure.ts +186 -0
- package/src/payments/tpay/tpayFailureEvidence.ts +75 -0
- package/src/payments/tpay/tpayHttpClient.ts +290 -0
- package/src/payments/tpay/tpayHttpFailure.ts +186 -0
- package/src/payments/tpay/tpayPaymentExecutionAdapter.ts +297 -0
- package/src/payments/tpay/tpayPaymentReconciliationProvider.ts +285 -0
- package/src/payments/tpay/tpayTransactionPayload.ts +207 -0
- package/src/payments/tpay/wallClockTime.ts +57 -0
- package/src/payments/tpay/webhookJwsVerifier.ts +82 -0
- package/src/payments/tpay/webhookNormalizer.ts +279 -0
- package/src/payments/verifiedEvidenceProjection.ts +179 -0
- package/src/payments/verifiedIngress.ts +317 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
-- Public platform settlement rail: the order gets lines and a price, and the kernel gets the one
|
|
2
|
+
-- thing it has never had -- a way to say "this order was paid for". Three relations, two money
|
|
3
|
+
-- columns on the order row a previous forward published, one arithmetic invariant, two mutations,
|
|
4
|
+
-- one emitter, and one delegation onto the subscription rail.
|
|
5
|
+
--
|
|
6
|
+
-- WHAT THIS FORWARD SHIPS. Every rail before this one could describe an order's LIFE -- a hold, a
|
|
7
|
+
-- parcel, a document, a cancellation -- and none of them could describe how it was BOUGHT. The
|
|
8
|
+
-- order-management forward said so about itself in the open: "no payment intent, payment, payment
|
|
9
|
+
-- attempt or inbound provider event relation... on this kernel the order row IS where payment state
|
|
10
|
+
-- lands". The accounting forward said the same from the other side: "no order money. No currency,
|
|
11
|
+
-- no order totals, no item relation". Both named the owner of that gap, and this is it. After this
|
|
12
|
+
-- forward a guest can be charged on a second-bundle deployment: the order carries lines and a
|
|
13
|
+
-- declared total, a settlement intent carries the amount somebody is being asked for, and one
|
|
14
|
+
-- accepted settlement moves the order to paid and the subscription cycle it belongs to with it.
|
|
15
|
+
--
|
|
16
|
+
-- WHAT IT REFUSES TO KNOW, AND THIS IS THE LOAD-BEARING PART. There is NO payment gateway here and
|
|
17
|
+
-- there will not be one. Not a name, not an enum, not a branch keyed on who is charging. A
|
|
18
|
+
-- settlement carries ONE opaque channel key and ONE opaque external reference; this kernel writes
|
|
19
|
+
-- both, compares them for equality, and never parses either. The reference deployment settles
|
|
20
|
+
-- through a channel that does nothing at all, which is exactly why the journey needs no real
|
|
21
|
+
-- gateway credentials to run. Currency is a COLUMN with a checked SHAPE -- three characters -- and
|
|
22
|
+
-- never a value: a kernel that hard-codes one market's currency has chosen that market for every
|
|
23
|
+
-- adopter. The same rule covers countries, tax rules and price rounding: none appears below.
|
|
24
|
+
--
|
|
25
|
+
-- MINOR UNITS, NOT CENTS, matching the vocabulary the accounting forward settled on. `cents` is the
|
|
26
|
+
-- subdivision of two particular currencies; `*_minor` is the subdivision of whichever one the
|
|
27
|
+
-- deployment declared.
|
|
28
|
+
--
|
|
29
|
+
-- WHERE THE SEMANTICS COME FROM, AND EVERY DEPARTURE. The behaviour below was derived from the
|
|
30
|
+
-- managed chain by reading its payment-control plane end to end -- intent creation, attempt
|
|
31
|
+
-- recording, result application, and the success branch that pays a subscription cycle -- and by
|
|
32
|
+
-- reading the constraints its own tables carry. Ten departures, each named, because a silent one is
|
|
33
|
+
-- how a kernel and its origin quietly stop being the same machine:
|
|
34
|
+
--
|
|
35
|
+
-- 1. ONE INTENT AND ONE LEDGER, NOT SIX RELATIONS. Upstream splits the money into an intent, a
|
|
36
|
+
-- payment, an attempt, a state-transition log, an inbound event store and a generic
|
|
37
|
+
-- idempotency table. Five of the six exist to hold gateway facts -- session handles, request
|
|
38
|
+
-- fingerprints, signature verification, next-action kinds -- and this kernel has no gateway,
|
|
39
|
+
-- so it would be authoring five empty shapes. What survives is the pair that carries meaning
|
|
40
|
+
-- without one: the intent, and the append-only ledger of what happened to it.
|
|
41
|
+
-- 2. IDEMPOTENCY IS PER INTENT, NOT GLOBAL. Upstream keys a shared table on (scope, key) and
|
|
42
|
+
-- fingerprints the request into it. Here the ledger row IS the replay answer and its
|
|
43
|
+
-- uniqueness is (intent, key), which is the invariant that lookup actually means: two
|
|
44
|
+
-- unrelated intents cannot collide on a value neither of them chose.
|
|
45
|
+
-- 3. THE SECOND SETTLEMENT OF A SETTLED INTENT IS REFUSED. Upstream re-applies a repeated success
|
|
46
|
+
-- arriving under a different key: the branch it takes only refuses cancelled and refunded
|
|
47
|
+
-- intents, so a second delivery writes a second state transition and stamps the row again.
|
|
48
|
+
-- This kernel answers `..._intent_already_settled` instead. It is a deliberately STRONGER
|
|
49
|
+
-- promise, it is what the settlement rail exists to guarantee, and the parity harness compares
|
|
50
|
+
-- it as a departure rather than hiding it in the shared spine.
|
|
51
|
+
-- 4. A DECLINE LEAVES THE ORDER RE-PAYABLE and writes nothing else. Upstream's decline branch
|
|
52
|
+
-- also opens a dunning case, schedules three retries on a fixed ladder, skips queued
|
|
53
|
+
-- notifications and classifies the failure. Dunning, retries and failure classification are
|
|
54
|
+
-- capabilities of their own with their own forwards; what is portable is that a declined order
|
|
55
|
+
-- is still payable, and that is all this kernel promises.
|
|
56
|
+
-- 5. THE INVARIANT IS A DEFERRED CONSTRAINT TRIGGER over the order and its lines together, the
|
|
57
|
+
-- device the accounting forward settled on. An argument check would be satisfied once and then
|
|
58
|
+
-- bypassed by the next write to the line ledger. The predicate is one comparison and it holds
|
|
59
|
+
-- in BOTH directions: an order that declares no total may carry no lines, and an order that
|
|
60
|
+
-- declares one must carry lines summing to it exactly.
|
|
61
|
+
-- 6. A LINE IS QUANTITY TIMES UNIT PRICE, exactly. Upstream carries a per-line discount
|
|
62
|
+
-- allocation, an effective net, and a basis-point rate, and refuses on eight arithmetic
|
|
63
|
+
-- mismatches between them. Discounting is a pricing decision the catalogue capability owns and
|
|
64
|
+
-- tax is a rule this catalogue already declined to interpret; a kernel that stored either
|
|
65
|
+
-- without applying it would be storing a claim nobody checks.
|
|
66
|
+
-- 7. THE MONEY COLUMNS ARE NULLABLE AND ADDED, not required and rewritten. They are added to a
|
|
67
|
+
-- table two published forwards already created, so NOT NULL there would refuse to apply on any
|
|
68
|
+
-- instance carrying rows. A NULL total means "this order has not declared what it is worth",
|
|
69
|
+
-- which is exactly the state every order on this kernel was in before this forward.
|
|
70
|
+
-- 8. THE SUBSCRIPTION SIDE IS A DELEGATION, NOT A SECOND MACHINE. A settled order that belongs to
|
|
71
|
+
-- a cycle moves that cycle to `paid` and slides the subscription's next date one cadence past
|
|
72
|
+
-- the cycle it just paid for -- the same arithmetic the managed success branch does. It does
|
|
73
|
+
-- NOT terminalize, activate or cancel anything: the lifecycle verbs belong to the subscription
|
|
74
|
+
-- forward and are reached through it.
|
|
75
|
+
-- 9. NO `SECURITY DEFINER`, NO `GRANT`/`REVOKE`, NO ROW-LEVEL SECURITY, matching every forward in
|
|
76
|
+
-- this catalogue -- upstream every function below is `SECURITY DEFINER`. This kernel creates
|
|
77
|
+
-- zero roles. Said out loud rather than assumed: ON THIS KERNEL THE FUNCTIONS BELOW ARE NOT A
|
|
78
|
+
-- SECURITY BOUNDARY, AND NEITHER IS A SETTLEMENT AN AUTHORIZATION. A caller that can reach
|
|
79
|
+
-- these functions can already reach the tables; the host application is what authenticates and
|
|
80
|
+
-- what decides that money actually moved. `SET search_path` is kept, because that one is a
|
|
81
|
+
-- correctness property and not a role property.
|
|
82
|
+
-- 10. EVERY CONSTRAINT IS NAMED, and `CREATE` is unconditional, because a manifest-ordered forward
|
|
83
|
+
-- runs exactly once against a known prefix and `IF NOT EXISTS` there would hide the drift the
|
|
84
|
+
-- migration ledger exists to catch. Constraints ADDED to the published order table are
|
|
85
|
+
-- `NOT VALID`, following that table's own convention: every new write is checked from this
|
|
86
|
+
-- statement on, and a fresh kernel boot has nothing to validate anyway.
|
|
87
|
+
--
|
|
88
|
+
-- WHAT THIS FORWARD DELIBERATELY DOES NOT SHIP, enumerated so nobody has to infer it:
|
|
89
|
+
-- * no gateway, no adapter, no provider enum, no webhook ingestion, no signature verification and
|
|
90
|
+
-- no reconciliation sweep;
|
|
91
|
+
-- * no refund and no dispute. The order rail already owns the refunded transition and its
|
|
92
|
+
-- notification; a settlement that could also un-settle itself would be two machines;
|
|
93
|
+
-- * no dunning ledger, no retry schedule, no failure taxonomy, no bound payment method;
|
|
94
|
+
-- * no inventory reservation and no release. A settled order reserves nothing here;
|
|
95
|
+
-- * no shipping, no discount and no tax arithmetic anywhere;
|
|
96
|
+
-- * no identity. `p_*` arguments are supplied by a trusted caller; a guest order is an order
|
|
97
|
+
-- whose client row exists and whose caller was never asked to prove anything;
|
|
98
|
+
-- * no runtime binding. Not one line of the application is rewired onto these relations by this
|
|
99
|
+
-- forward; the falsifier is its own cross-bundle parity harness.
|
|
100
|
+
|
|
101
|
+
-- The order row two published forwards created carried a status, three instants and an owner. These
|
|
102
|
+
-- are the two columns that make it an order for GOODS AT A PRICE rather than a state machine with a
|
|
103
|
+
-- customer attached. Nullable, for departure 7.
|
|
104
|
+
ALTER TABLE public.commerce_orders
|
|
105
|
+
ADD COLUMN currency_code text,
|
|
106
|
+
ADD COLUMN total_amount_minor bigint;
|
|
107
|
+
|
|
108
|
+
-- Declared together or not at all: an amount without its unit is a number, and a unit without an
|
|
109
|
+
-- amount is a claim about nothing. The shape of the code is checked; its value never is.
|
|
110
|
+
ALTER TABLE public.commerce_orders
|
|
111
|
+
ADD CONSTRAINT commerce_orders_declared_money_check
|
|
112
|
+
CHECK (
|
|
113
|
+
(total_amount_minor IS NULL AND currency_code IS NULL)
|
|
114
|
+
OR (
|
|
115
|
+
total_amount_minor IS NOT NULL AND total_amount_minor >= 0
|
|
116
|
+
AND currency_code IS NOT NULL AND char_length(currency_code) = 3
|
|
117
|
+
)
|
|
118
|
+
) NOT VALID;
|
|
119
|
+
|
|
120
|
+
COMMENT ON COLUMN public.commerce_orders.currency_code IS
|
|
121
|
+
'Three-character code the deployment declared. This kernel checks its shape and never its value.';
|
|
122
|
+
|
|
123
|
+
-- One row per line of the order. The catalogue link is optional on purpose: an adopter may sell a
|
|
124
|
+
-- thing this kernel does not stock, and refusing the line would make the catalogue rail a
|
|
125
|
+
-- precondition for taking money.
|
|
126
|
+
CREATE TABLE public.commerce_order_items (
|
|
127
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
128
|
+
order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE CASCADE,
|
|
129
|
+
sku_id uuid REFERENCES public.catalog_skus(id) ON DELETE RESTRICT,
|
|
130
|
+
line_ordinal integer NOT NULL,
|
|
131
|
+
quantity integer NOT NULL,
|
|
132
|
+
unit_amount_minor bigint NOT NULL,
|
|
133
|
+
line_amount_minor bigint NOT NULL,
|
|
134
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
135
|
+
CONSTRAINT commerce_order_items_line_ordinal_check CHECK (line_ordinal > 0),
|
|
136
|
+
CONSTRAINT commerce_order_items_quantity_check CHECK (quantity > 0),
|
|
137
|
+
CONSTRAINT commerce_order_items_unit_amount_minor_check CHECK (unit_amount_minor >= 0),
|
|
138
|
+
-- Departure 6: the line is the multiplication, with nothing allocated onto it and nothing taken
|
|
139
|
+
-- out of it. Integer arithmetic, so it is exact in every currency and rounds in none.
|
|
140
|
+
CONSTRAINT commerce_order_items_line_amount_minor_check
|
|
141
|
+
CHECK (line_amount_minor = unit_amount_minor * quantity),
|
|
142
|
+
CONSTRAINT commerce_order_items_order_id_line_ordinal_key UNIQUE (order_id, line_ordinal)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
CREATE INDEX idx_commerce_order_items_order
|
|
146
|
+
ON public.commerce_order_items (order_id, line_ordinal);
|
|
147
|
+
|
|
148
|
+
-- What somebody is being asked to pay, and where that request stands. One per order, as a unique
|
|
149
|
+
-- key rather than a procedural check, because two concurrent callers cannot both pass a read and
|
|
150
|
+
-- then both insert.
|
|
151
|
+
CREATE TABLE public.commerce_settlement_intents (
|
|
152
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
153
|
+
order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE RESTRICT,
|
|
154
|
+
channel_key text NOT NULL,
|
|
155
|
+
status text NOT NULL DEFAULT 'created',
|
|
156
|
+
amount_minor bigint NOT NULL,
|
|
157
|
+
currency_code text NOT NULL,
|
|
158
|
+
external_ref text,
|
|
159
|
+
open_idempotency_key text NOT NULL,
|
|
160
|
+
settled_at timestamptz,
|
|
161
|
+
failure_reason text,
|
|
162
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
163
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
164
|
+
-- Three values, and the third is not terminal: a declined settlement can be tried again, which is
|
|
165
|
+
-- departure 4 expressed as a state rather than as a comment.
|
|
166
|
+
CONSTRAINT commerce_settlement_intents_status_check
|
|
167
|
+
CHECK (status IN ('created', 'settled', 'failed')),
|
|
168
|
+
CONSTRAINT commerce_settlement_intents_amount_minor_check CHECK (amount_minor > 0),
|
|
169
|
+
CONSTRAINT commerce_settlement_intents_currency_code_check CHECK (char_length(currency_code) = 3),
|
|
170
|
+
CONSTRAINT commerce_settlement_intents_channel_key_check
|
|
171
|
+
CHECK (btrim(channel_key) <> '' AND char_length(channel_key) <= 64),
|
|
172
|
+
CONSTRAINT commerce_settlement_intents_open_idempotency_key_check
|
|
173
|
+
CHECK (btrim(open_idempotency_key) <> ''),
|
|
174
|
+
-- The instant and the state cannot disagree, in either direction.
|
|
175
|
+
CONSTRAINT commerce_settlement_intents_settled_at_check
|
|
176
|
+
CHECK ((status = 'settled') = (settled_at IS NOT NULL)),
|
|
177
|
+
CONSTRAINT commerce_settlement_intents_order_id_key UNIQUE (order_id),
|
|
178
|
+
CONSTRAINT commerce_settlement_intents_open_idempotency_key_key UNIQUE (open_idempotency_key)
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
COMMENT ON COLUMN public.commerce_settlement_intents.channel_key IS
|
|
182
|
+
'Opaque label naming whatever settles this intent in a given deployment. Never parsed here.';
|
|
183
|
+
COMMENT ON COLUMN public.commerce_settlement_intents.external_ref IS
|
|
184
|
+
'Opaque handle handed back by that channel, written once. Never parsed here.';
|
|
185
|
+
|
|
186
|
+
-- Append-only, and the row IS the replay answer: a repeated request carrying a key this intent has
|
|
187
|
+
-- already seen is answered from the ledger instead of acted on twice. Uniqueness is per intent and
|
|
188
|
+
-- the key is mandatory, for departure 2.
|
|
189
|
+
CREATE TABLE public.commerce_settlement_transitions (
|
|
190
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
191
|
+
intent_id uuid NOT NULL REFERENCES public.commerce_settlement_intents(id) ON DELETE CASCADE,
|
|
192
|
+
from_status text NOT NULL,
|
|
193
|
+
to_status text NOT NULL,
|
|
194
|
+
outcome text NOT NULL,
|
|
195
|
+
idempotency_key text NOT NULL,
|
|
196
|
+
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
197
|
+
occurred_at timestamptz NOT NULL DEFAULT now(),
|
|
198
|
+
CONSTRAINT commerce_settlement_transitions_outcome_check
|
|
199
|
+
CHECK (outcome IN ('opened', 'succeeded', 'failed')),
|
|
200
|
+
CONSTRAINT commerce_settlement_transitions_idempotency_key_check
|
|
201
|
+
CHECK (btrim(idempotency_key) <> ''),
|
|
202
|
+
CONSTRAINT commerce_settlement_transitions_intent_id_idempotency_key_key
|
|
203
|
+
UNIQUE (intent_id, idempotency_key)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
CREATE INDEX idx_commerce_settlement_transitions_intent
|
|
207
|
+
ON public.commerce_settlement_transitions (intent_id, occurred_at DESC);
|
|
208
|
+
|
|
209
|
+
-- THE INVARIANT. The lines must equal the declared total, in minor units, exactly.
|
|
210
|
+
--
|
|
211
|
+
-- One predicate covers both directions, which is why it is one predicate: an order that declares
|
|
212
|
+
-- nothing must carry no lines (0 = 0), an order that declares an amount must carry lines summing to
|
|
213
|
+
-- it, and an order that carries lines without declaring anything fails on the same comparison.
|
|
214
|
+
-- Deferring it to the end of the transaction is what lets an order and its lines be written by one
|
|
215
|
+
-- call and still be checked as a pair; nothing can commit them in disagreement.
|
|
216
|
+
CREATE FUNCTION public.commerce_order_assert_line_sum()
|
|
217
|
+
RETURNS trigger
|
|
218
|
+
LANGUAGE plpgsql
|
|
219
|
+
SET search_path = public, pg_catalog
|
|
220
|
+
AS $$
|
|
221
|
+
DECLARE
|
|
222
|
+
v_order_id uuid;
|
|
223
|
+
v_declared bigint;
|
|
224
|
+
v_sum bigint;
|
|
225
|
+
BEGIN
|
|
226
|
+
-- Branching statements rather than one CASE expression on purpose: the procedural language
|
|
227
|
+
-- compiles an expression into a single query, so every record field named in any arm must exist
|
|
228
|
+
-- on every row type the trigger can see, and the two row types here differ.
|
|
229
|
+
IF TG_TABLE_NAME = 'commerce_orders' THEN
|
|
230
|
+
v_order_id := NEW.id;
|
|
231
|
+
ELSIF TG_OP = 'DELETE' THEN
|
|
232
|
+
v_order_id := OLD.order_id;
|
|
233
|
+
ELSE
|
|
234
|
+
v_order_id := NEW.order_id;
|
|
235
|
+
END IF;
|
|
236
|
+
|
|
237
|
+
SELECT total_amount_minor INTO v_declared
|
|
238
|
+
FROM public.commerce_orders
|
|
239
|
+
WHERE id = v_order_id;
|
|
240
|
+
-- The order is gone: a cascade took it, and there is nothing left to be consistent with.
|
|
241
|
+
IF NOT FOUND THEN
|
|
242
|
+
RETURN NULL;
|
|
243
|
+
END IF;
|
|
244
|
+
|
|
245
|
+
SELECT COALESCE(sum(line_amount_minor), 0) INTO v_sum
|
|
246
|
+
FROM public.commerce_order_items
|
|
247
|
+
WHERE order_id = v_order_id;
|
|
248
|
+
|
|
249
|
+
IF v_sum IS DISTINCT FROM COALESCE(v_declared, 0) THEN
|
|
250
|
+
RAISE EXCEPTION 'commerce_order_line_sum_mismatch' USING ERRCODE = '23514',
|
|
251
|
+
DETAIL = format('order %s declares %s and its lines sum to %s',
|
|
252
|
+
v_order_id, COALESCE(v_declared::text, 'nothing'), v_sum);
|
|
253
|
+
END IF;
|
|
254
|
+
|
|
255
|
+
RETURN NULL;
|
|
256
|
+
END;
|
|
257
|
+
$$;
|
|
258
|
+
|
|
259
|
+
CREATE CONSTRAINT TRIGGER trg_commerce_orders_line_sum
|
|
260
|
+
AFTER INSERT OR UPDATE ON public.commerce_orders
|
|
261
|
+
DEFERRABLE INITIALLY DEFERRED
|
|
262
|
+
FOR EACH ROW
|
|
263
|
+
EXECUTE FUNCTION public.commerce_order_assert_line_sum();
|
|
264
|
+
|
|
265
|
+
CREATE CONSTRAINT TRIGGER trg_commerce_order_items_line_sum
|
|
266
|
+
AFTER INSERT OR UPDATE OR DELETE ON public.commerce_order_items
|
|
267
|
+
DEFERRABLE INITIALLY DEFERRED
|
|
268
|
+
FOR EACH ROW
|
|
269
|
+
EXECUTE FUNCTION public.commerce_order_assert_line_sum();
|
|
270
|
+
|
|
271
|
+
-- One response shape for both entry points, so an accepted call and a replayed one are answered by
|
|
272
|
+
-- the same builder and cannot drift apart.
|
|
273
|
+
CREATE FUNCTION public.commerce_settlement_response(
|
|
274
|
+
p_intent public.commerce_settlement_intents,
|
|
275
|
+
p_transition_id uuid,
|
|
276
|
+
p_outcome text,
|
|
277
|
+
p_replayed boolean
|
|
278
|
+
)
|
|
279
|
+
RETURNS jsonb
|
|
280
|
+
LANGUAGE sql
|
|
281
|
+
IMMUTABLE
|
|
282
|
+
SET search_path = public, pg_catalog
|
|
283
|
+
AS $$
|
|
284
|
+
SELECT jsonb_build_object(
|
|
285
|
+
'contractVersion', 'platform.commerce.settlement.v1',
|
|
286
|
+
'outcome', p_outcome,
|
|
287
|
+
'replayed', p_replayed,
|
|
288
|
+
'transitionId', p_transition_id,
|
|
289
|
+
'settlement', jsonb_build_object(
|
|
290
|
+
'id', p_intent.id,
|
|
291
|
+
'orderId', p_intent.order_id,
|
|
292
|
+
'status', p_intent.status,
|
|
293
|
+
'amountMinor', p_intent.amount_minor,
|
|
294
|
+
'currencyCode', p_intent.currency_code,
|
|
295
|
+
'externalRef', p_intent.external_ref,
|
|
296
|
+
'failureReason', p_intent.failure_reason));
|
|
297
|
+
$$;
|
|
298
|
+
|
|
299
|
+
-- Ask for an order to be paid.
|
|
300
|
+
--
|
|
301
|
+
-- The ordering of the first three steps is the contract, not a style choice:
|
|
302
|
+
-- 1. the arguments are validated before anything is read, so a malformed request never takes a
|
|
303
|
+
-- lock;
|
|
304
|
+
-- 2. THE ORDER ROW IS LOCKED, and only then;
|
|
305
|
+
-- 3. the intent is looked for.
|
|
306
|
+
-- Two callers arriving at once serialize on the order row, and the one that loses the race re-reads
|
|
307
|
+
-- a table that now contains the winner's intent -- so a repeated request answers `replayed` and a
|
|
308
|
+
-- second, different request is refused, instead of both passing a read and both inserting.
|
|
309
|
+
--
|
|
310
|
+
-- The amount is not trusted and not derived: it is COMPARED. A caller that asks for a different
|
|
311
|
+
-- number than the order declares is refused, which is the only thing this kernel enforces about
|
|
312
|
+
-- money at the moment of asking for it.
|
|
313
|
+
CREATE FUNCTION public.commerce_open_settlement_intent(
|
|
314
|
+
p_idempotency_key text,
|
|
315
|
+
p_order_id uuid,
|
|
316
|
+
p_channel_key text,
|
|
317
|
+
p_amount_minor bigint,
|
|
318
|
+
p_currency_code text,
|
|
319
|
+
p_metadata jsonb DEFAULT '{}'::jsonb,
|
|
320
|
+
p_requested_at timestamptz DEFAULT now()
|
|
321
|
+
)
|
|
322
|
+
RETURNS jsonb
|
|
323
|
+
LANGUAGE plpgsql
|
|
324
|
+
SET search_path = public, pg_catalog
|
|
325
|
+
AS $$
|
|
326
|
+
DECLARE
|
|
327
|
+
v_order public.commerce_orders%ROWTYPE;
|
|
328
|
+
v_intent public.commerce_settlement_intents%ROWTYPE;
|
|
329
|
+
v_transition public.commerce_settlement_transitions%ROWTYPE;
|
|
330
|
+
BEGIN
|
|
331
|
+
-- Long enough that a caller cannot reach it by accident: an idempotency key short enough to
|
|
332
|
+
-- collide is worse than no key, because it silently suppresses a real second request.
|
|
333
|
+
IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
|
|
334
|
+
OR p_order_id IS NULL
|
|
335
|
+
OR p_channel_key IS NULL OR btrim(p_channel_key) = '' OR char_length(p_channel_key) > 64
|
|
336
|
+
OR p_amount_minor IS NULL OR p_amount_minor < 1
|
|
337
|
+
OR p_currency_code IS NULL OR char_length(p_currency_code) <> 3
|
|
338
|
+
THEN
|
|
339
|
+
RAISE EXCEPTION 'commerce_settlement_open_invalid_input' USING ERRCODE = '22023';
|
|
340
|
+
END IF;
|
|
341
|
+
|
|
342
|
+
SELECT * INTO v_order FROM public.commerce_orders WHERE id = p_order_id FOR UPDATE;
|
|
343
|
+
IF NOT FOUND THEN
|
|
344
|
+
RAISE EXCEPTION 'commerce_settlement_open_order_not_found' USING ERRCODE = '22023';
|
|
345
|
+
END IF;
|
|
346
|
+
|
|
347
|
+
SELECT * INTO v_intent
|
|
348
|
+
FROM public.commerce_settlement_intents WHERE order_id = p_order_id;
|
|
349
|
+
|
|
350
|
+
IF FOUND THEN
|
|
351
|
+
IF v_intent.open_idempotency_key IS DISTINCT FROM p_idempotency_key THEN
|
|
352
|
+
RAISE EXCEPTION 'commerce_settlement_open_order_already_has_intent' USING ERRCODE = '23505';
|
|
353
|
+
END IF;
|
|
354
|
+
SELECT * INTO v_transition
|
|
355
|
+
FROM public.commerce_settlement_transitions
|
|
356
|
+
WHERE intent_id = v_intent.id AND idempotency_key = p_idempotency_key;
|
|
357
|
+
RETURN public.commerce_settlement_response(v_intent, v_transition.id, 'opened', true);
|
|
358
|
+
END IF;
|
|
359
|
+
|
|
360
|
+
IF v_order.status <> 'pending_payment' THEN
|
|
361
|
+
RAISE EXCEPTION 'commerce_settlement_open_order_not_payable' USING ERRCODE = '22023';
|
|
362
|
+
END IF;
|
|
363
|
+
|
|
364
|
+
IF v_order.total_amount_minor IS DISTINCT FROM p_amount_minor
|
|
365
|
+
OR v_order.currency_code IS DISTINCT FROM p_currency_code
|
|
366
|
+
THEN
|
|
367
|
+
RAISE EXCEPTION 'commerce_settlement_open_amount_mismatch' USING ERRCODE = '22023';
|
|
368
|
+
END IF;
|
|
369
|
+
|
|
370
|
+
INSERT INTO public.commerce_settlement_intents
|
|
371
|
+
(order_id, channel_key, status, amount_minor, currency_code, open_idempotency_key,
|
|
372
|
+
created_at, updated_at)
|
|
373
|
+
VALUES (p_order_id, btrim(p_channel_key), 'created', p_amount_minor, p_currency_code,
|
|
374
|
+
p_idempotency_key, p_requested_at, p_requested_at)
|
|
375
|
+
RETURNING * INTO v_intent;
|
|
376
|
+
|
|
377
|
+
INSERT INTO public.commerce_settlement_transitions
|
|
378
|
+
(intent_id, from_status, to_status, outcome, idempotency_key, payload, occurred_at)
|
|
379
|
+
VALUES (v_intent.id, 'absent', 'created', 'opened', p_idempotency_key,
|
|
380
|
+
COALESCE(p_metadata, '{}'::jsonb), p_requested_at)
|
|
381
|
+
RETURNING * INTO v_transition;
|
|
382
|
+
|
|
383
|
+
RETURN public.commerce_settlement_response(v_intent, v_transition.id, 'opened', false);
|
|
384
|
+
END;
|
|
385
|
+
$$;
|
|
386
|
+
|
|
387
|
+
-- Record what the settlement channel answered.
|
|
388
|
+
--
|
|
389
|
+
-- Same discipline as above and for the same reason: validate, LOCK, then consult the ledger for a
|
|
390
|
+
-- replay. Consulting the ledger first would let two callers pass, which is the classic
|
|
391
|
+
-- read-then-write race this function is written to not have. The locks are taken ORDER FIRST and
|
|
392
|
+
-- intent second, in the same sequence the opening function takes them, so a caller opening an
|
|
393
|
+
-- intent and a caller settling one cannot deadlock against each other.
|
|
394
|
+
--
|
|
395
|
+
-- Departure 3 lives in one statement below: an intent that has already been settled refuses a
|
|
396
|
+
-- second settlement outright. A repeated delivery under the SAME key is a replay and is answered
|
|
397
|
+
-- from the ledger; a second, different request against money that has already moved is a bug in the
|
|
398
|
+
-- caller, and answering it would let one order be paid for twice.
|
|
399
|
+
CREATE FUNCTION public.commerce_record_settlement(
|
|
400
|
+
p_idempotency_key text,
|
|
401
|
+
p_intent_id uuid,
|
|
402
|
+
p_outcome text,
|
|
403
|
+
p_external_ref text DEFAULT NULL,
|
|
404
|
+
p_metadata jsonb DEFAULT '{}'::jsonb,
|
|
405
|
+
p_occurred_at timestamptz DEFAULT now()
|
|
406
|
+
)
|
|
407
|
+
RETURNS jsonb
|
|
408
|
+
LANGUAGE plpgsql
|
|
409
|
+
SET search_path = public, pg_catalog
|
|
410
|
+
AS $$
|
|
411
|
+
DECLARE
|
|
412
|
+
v_intent public.commerce_settlement_intents%ROWTYPE;
|
|
413
|
+
v_from text;
|
|
414
|
+
v_transition public.commerce_settlement_transitions%ROWTYPE;
|
|
415
|
+
v_order public.commerce_orders%ROWTYPE;
|
|
416
|
+
v_cycle public.subscription_cycles%ROWTYPE;
|
|
417
|
+
v_subscription public.subscriptions%ROWTYPE;
|
|
418
|
+
BEGIN
|
|
419
|
+
IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
|
|
420
|
+
OR p_intent_id IS NULL
|
|
421
|
+
OR p_outcome IS NULL OR p_outcome NOT IN ('succeeded', 'failed')
|
|
422
|
+
THEN
|
|
423
|
+
RAISE EXCEPTION 'commerce_settlement_record_invalid_input' USING ERRCODE = '22023';
|
|
424
|
+
END IF;
|
|
425
|
+
|
|
426
|
+
SELECT * INTO v_intent
|
|
427
|
+
FROM public.commerce_settlement_intents WHERE id = p_intent_id;
|
|
428
|
+
IF NOT FOUND THEN
|
|
429
|
+
RAISE EXCEPTION 'commerce_settlement_record_intent_not_found' USING ERRCODE = '22023';
|
|
430
|
+
END IF;
|
|
431
|
+
|
|
432
|
+
SELECT * INTO v_order
|
|
433
|
+
FROM public.commerce_orders WHERE id = v_intent.order_id FOR UPDATE;
|
|
434
|
+
|
|
435
|
+
SELECT * INTO v_intent
|
|
436
|
+
FROM public.commerce_settlement_intents WHERE id = p_intent_id FOR UPDATE;
|
|
437
|
+
|
|
438
|
+
SELECT * INTO v_transition
|
|
439
|
+
FROM public.commerce_settlement_transitions
|
|
440
|
+
WHERE intent_id = p_intent_id AND idempotency_key = p_idempotency_key;
|
|
441
|
+
IF FOUND THEN
|
|
442
|
+
RETURN public.commerce_settlement_response(v_intent, v_transition.id, v_transition.outcome, true);
|
|
443
|
+
END IF;
|
|
444
|
+
|
|
445
|
+
IF v_intent.status = 'settled' THEN
|
|
446
|
+
RAISE EXCEPTION 'commerce_settlement_record_intent_already_settled' USING ERRCODE = '22023';
|
|
447
|
+
END IF;
|
|
448
|
+
|
|
449
|
+
-- Write-once, and compared rather than overwritten: a channel that hands back a different handle
|
|
450
|
+
-- for the same intent is describing a different payment.
|
|
451
|
+
IF v_intent.external_ref IS NOT NULL AND p_external_ref IS NOT NULL
|
|
452
|
+
AND v_intent.external_ref IS DISTINCT FROM p_external_ref
|
|
453
|
+
THEN
|
|
454
|
+
RAISE EXCEPTION 'commerce_settlement_record_external_reference_immutable' USING ERRCODE = '22023';
|
|
455
|
+
END IF;
|
|
456
|
+
|
|
457
|
+
v_from := v_intent.status;
|
|
458
|
+
|
|
459
|
+
IF p_outcome = 'succeeded' THEN
|
|
460
|
+
-- An order somebody took off the table between the ask and the answer is not paid into. The
|
|
461
|
+
-- money question then belongs to whoever cancelled it, and this kernel has no refund verb.
|
|
462
|
+
IF v_order.status <> 'pending_payment' THEN
|
|
463
|
+
RAISE EXCEPTION 'commerce_settlement_record_order_not_payable' USING ERRCODE = '22023';
|
|
464
|
+
END IF;
|
|
465
|
+
|
|
466
|
+
UPDATE public.commerce_settlement_intents
|
|
467
|
+
SET status = 'settled',
|
|
468
|
+
settled_at = p_occurred_at,
|
|
469
|
+
external_ref = COALESCE(external_ref, p_external_ref),
|
|
470
|
+
failure_reason = NULL,
|
|
471
|
+
updated_at = p_occurred_at
|
|
472
|
+
WHERE id = p_intent_id
|
|
473
|
+
RETURNING * INTO v_intent;
|
|
474
|
+
|
|
475
|
+
-- The order transition is the delegation onto the order rail: that rail owns `paid`, owns the
|
|
476
|
+
-- notification it produces, and its emitter fires from this statement without this forward
|
|
477
|
+
-- restating a single line of it.
|
|
478
|
+
UPDATE public.commerce_orders
|
|
479
|
+
SET status = 'paid', paid_at = p_occurred_at, updated_at = p_occurred_at
|
|
480
|
+
WHERE id = v_intent.order_id
|
|
481
|
+
RETURNING * INTO v_order;
|
|
482
|
+
|
|
483
|
+
IF v_order.subscription_cycle_id IS NOT NULL THEN
|
|
484
|
+
SELECT * INTO v_cycle
|
|
485
|
+
FROM public.subscription_cycles WHERE id = v_order.subscription_cycle_id FOR UPDATE;
|
|
486
|
+
IF NOT FOUND THEN
|
|
487
|
+
RAISE EXCEPTION 'commerce_settlement_record_cycle_not_found' USING ERRCODE = '22023';
|
|
488
|
+
END IF;
|
|
489
|
+
IF v_cycle.status NOT IN ('planned', 'payment_pending') THEN
|
|
490
|
+
RAISE EXCEPTION 'commerce_settlement_record_cycle_not_payable' USING ERRCODE = '22023';
|
|
491
|
+
END IF;
|
|
492
|
+
|
|
493
|
+
SELECT * INTO v_subscription
|
|
494
|
+
FROM public.subscriptions WHERE id = v_cycle.subscription_id FOR UPDATE;
|
|
495
|
+
|
|
496
|
+
UPDATE public.subscription_cycles
|
|
497
|
+
SET status = 'paid', updated_at = p_occurred_at
|
|
498
|
+
WHERE id = v_cycle.id;
|
|
499
|
+
|
|
500
|
+
-- Departure 8: one cadence past the cycle that was just paid for, not past the moment the
|
|
501
|
+
-- money arrived -- whole cadences forward keep every later date on the original grid. A
|
|
502
|
+
-- subscription nobody is running any more is not slid: settlement truth still applies to a
|
|
503
|
+
-- cancelled subscription, but its schedule is not this function's to revive.
|
|
504
|
+
UPDATE public.subscriptions
|
|
505
|
+
SET next_cycle_at = v_cycle.scheduled_at + (cadence_days || ' days')::interval,
|
|
506
|
+
updated_at = p_occurred_at
|
|
507
|
+
WHERE id = v_subscription.id
|
|
508
|
+
AND status = 'active';
|
|
509
|
+
|
|
510
|
+
INSERT INTO public.subscription_events
|
|
511
|
+
(subscription_id, event_type, idempotency_key, payload, occurred_at)
|
|
512
|
+
VALUES (v_subscription.id, 'subscription.cycle_settled',
|
|
513
|
+
p_idempotency_key || ':subscription',
|
|
514
|
+
jsonb_build_object('cycleId', v_cycle.id, 'orderId', v_order.id,
|
|
515
|
+
'settlementId', v_intent.id),
|
|
516
|
+
p_occurred_at)
|
|
517
|
+
ON CONFLICT (subscription_id, idempotency_key) DO NOTHING;
|
|
518
|
+
END IF;
|
|
519
|
+
ELSE
|
|
520
|
+
-- Departure 4: the order is untouched, so it stays `pending_payment` and can be settled again.
|
|
521
|
+
UPDATE public.commerce_settlement_intents
|
|
522
|
+
SET status = 'failed',
|
|
523
|
+
external_ref = COALESCE(external_ref, p_external_ref),
|
|
524
|
+
failure_reason = COALESCE(NULLIF(btrim(COALESCE(p_metadata, '{}'::jsonb)->>'reason'), ''),
|
|
525
|
+
'declined'),
|
|
526
|
+
updated_at = p_occurred_at
|
|
527
|
+
WHERE id = p_intent_id
|
|
528
|
+
RETURNING * INTO v_intent;
|
|
529
|
+
END IF;
|
|
530
|
+
|
|
531
|
+
INSERT INTO public.commerce_settlement_transitions
|
|
532
|
+
(intent_id, from_status, to_status, outcome, idempotency_key, payload, occurred_at)
|
|
533
|
+
VALUES (p_intent_id, v_from, v_intent.status, p_outcome, p_idempotency_key,
|
|
534
|
+
COALESCE(p_metadata, '{}'::jsonb), p_occurred_at)
|
|
535
|
+
RETURNING * INTO v_transition;
|
|
536
|
+
|
|
537
|
+
RETURN public.commerce_settlement_response(v_intent, v_transition.id, p_outcome, false);
|
|
538
|
+
END;
|
|
539
|
+
$$;
|
|
540
|
+
|
|
541
|
+
-- THE SETTLEMENT NOTIFICATION, and it is this capability's own rather than the order rail's. The
|
|
542
|
+
-- order rail already says "this order is paid"; what only this rail can say is "the money for it
|
|
543
|
+
-- was settled through the channel that was asked", and a capability that owns a notification owns
|
|
544
|
+
-- its own enqueue.
|
|
545
|
+
--
|
|
546
|
+
-- The barrier lives in the trigger's WHEN clause rather than in an early RETURN inside the body: a
|
|
547
|
+
-- condition the planner evaluates before the function is entered cannot be reached by a statement
|
|
548
|
+
-- that did not change what it claims to have changed. The emitter is idempotent on
|
|
549
|
+
-- (event type, key) as well, so a replayed settlement cannot double-notify.
|
|
550
|
+
CREATE FUNCTION public.commerce_settlement_emit_settled_notification()
|
|
551
|
+
RETURNS trigger
|
|
552
|
+
LANGUAGE plpgsql
|
|
553
|
+
SET search_path = public, pg_catalog
|
|
554
|
+
AS $$
|
|
555
|
+
BEGIN
|
|
556
|
+
INSERT INTO public.outbox_events
|
|
557
|
+
(aggregate_type, aggregate_id, event_type, idempotency_key, payload, metadata)
|
|
558
|
+
VALUES ('commerce_order', NEW.order_id, 'commerce.settlement.settled',
|
|
559
|
+
'settlement_settled:' || NEW.id::text,
|
|
560
|
+
jsonb_build_object('orderUuid', NEW.order_id, 'settlementUuid', NEW.id,
|
|
561
|
+
'amountMinor', NEW.amount_minor, 'currencyCode', NEW.currency_code,
|
|
562
|
+
'occurredAt', NEW.settled_at),
|
|
563
|
+
jsonb_build_object('source', 'commerce_settlement_emit_settled_notification'))
|
|
564
|
+
ON CONFLICT (event_type, idempotency_key) DO NOTHING;
|
|
565
|
+
RETURN NULL;
|
|
566
|
+
END;
|
|
567
|
+
$$;
|
|
568
|
+
|
|
569
|
+
CREATE TRIGGER trg_commerce_settlement_emit_settled
|
|
570
|
+
AFTER UPDATE OF status ON public.commerce_settlement_intents
|
|
571
|
+
FOR EACH ROW
|
|
572
|
+
WHEN (NEW.status = 'settled' AND OLD.status IS DISTINCT FROM 'settled'
|
|
573
|
+
AND NEW.settled_at IS NOT NULL)
|
|
574
|
+
EXECUTE FUNCTION public.commerce_settlement_emit_settled_notification();
|