@openlup/platform 0.23.0 → 0.25.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.
Files changed (165) hide show
  1. package/CHANGELOG.md +327 -269
  2. package/README.md +123 -12
  3. package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
  4. package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
  5. package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
  6. package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
  7. package/dist/composition/index.d.ts +1 -0
  8. package/dist/composition/index.js +1 -0
  9. package/dist/composition/subscriptionContribution.d.ts +18 -0
  10. package/dist/composition/subscriptionContribution.js +19 -0
  11. package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
  12. package/dist/fulfillment/dispatchProjection.d.ts +1 -1
  13. package/dist/fulfillment/dispatchProjection.js +4 -2
  14. package/dist/fulfillment/omnipack/client.js +14 -6
  15. package/dist/fulfillment/omnipack/index.js +5 -2
  16. package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
  17. package/dist/fulfillment/providerDispatchWorker.js +19 -1
  18. package/dist/inventory/index.d.ts +2 -0
  19. package/dist/inventory/index.js +1 -0
  20. package/dist/inventory/postgresStockAvailability.d.ts +24 -0
  21. package/dist/inventory/postgresStockAvailability.js +48 -0
  22. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  23. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  24. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  25. package/dist/invoicing/accountingLineAllocation.js +69 -0
  26. package/dist/invoicing/adapterConformance.d.ts +42 -0
  27. package/dist/invoicing/adapterConformance.js +296 -0
  28. package/dist/invoicing/artifacts.d.ts +7 -0
  29. package/dist/invoicing/artifacts.js +20 -0
  30. package/dist/invoicing/bindings.d.ts +46 -0
  31. package/dist/invoicing/bindings.js +69 -0
  32. package/dist/invoicing/channelPolicy.d.ts +28 -0
  33. package/dist/invoicing/channelPolicy.js +27 -0
  34. package/dist/invoicing/continuity.d.ts +12 -0
  35. package/dist/invoicing/continuity.js +18 -0
  36. package/dist/invoicing/contracts/facts.d.ts +11 -1
  37. package/dist/invoicing/contracts/host.d.ts +3 -4
  38. package/dist/invoicing/contracts/index.d.ts +1 -1
  39. package/dist/invoicing/contracts/store.d.ts +15 -5
  40. package/dist/invoicing/contribution.d.ts +21 -0
  41. package/dist/invoicing/contribution.js +19 -0
  42. package/dist/invoicing/correction.d.ts +42 -0
  43. package/dist/invoicing/correction.js +44 -0
  44. package/dist/invoicing/delivery.d.ts +85 -0
  45. package/dist/invoicing/delivery.js +79 -0
  46. package/dist/invoicing/documentHistory.d.ts +50 -0
  47. package/dist/invoicing/documentHistory.js +213 -0
  48. package/dist/invoicing/documentSync.d.ts +24 -0
  49. package/dist/invoicing/documentSync.js +26 -0
  50. package/dist/invoicing/dutyCollector.d.ts +15 -0
  51. package/dist/invoicing/dutyCollector.js +35 -0
  52. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  53. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  54. package/dist/invoicing/handoff.d.ts +37 -0
  55. package/dist/invoicing/handoff.js +62 -0
  56. package/dist/invoicing/index.d.ts +38 -0
  57. package/dist/invoicing/index.js +36 -0
  58. package/dist/invoicing/issue.d.ts +39 -0
  59. package/dist/invoicing/issue.js +55 -0
  60. package/dist/invoicing/migrations.d.ts +48 -0
  61. package/dist/invoicing/migrations.js +25 -0
  62. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  63. package/dist/invoicing/nativeUnsupported.js +22 -0
  64. package/dist/invoicing/paymentReadback.d.ts +20 -0
  65. package/dist/invoicing/paymentReadback.js +47 -0
  66. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  67. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  68. package/dist/invoicing/readModel.d.ts +74 -0
  69. package/dist/invoicing/readModel.js +90 -0
  70. package/dist/invoicing/readback.d.ts +4 -0
  71. package/dist/invoicing/readback.js +30 -0
  72. package/dist/invoicing/reversal.d.ts +37 -0
  73. package/dist/invoicing/reversal.js +28 -0
  74. package/dist/invoicing/sellerRevision.d.ts +19 -0
  75. package/dist/invoicing/sellerRevision.js +14 -0
  76. package/dist/invoicing/settlementSync.d.ts +18 -0
  77. package/dist/invoicing/settlementSync.js +32 -0
  78. package/dist/invoicing/snapshot.d.ts +52 -0
  79. package/dist/invoicing/snapshot.js +141 -0
  80. package/dist/subscriptions/commandExamples.d.ts +6 -0
  81. package/dist/subscriptions/commandExamples.js +20 -0
  82. package/dist/subscriptions/contracts/commands.d.ts +8 -1
  83. package/dist/subscriptions/contracts/index.d.ts +2 -2
  84. package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
  85. package/dist/subscriptions/index.d.ts +22 -4
  86. package/dist/subscriptions/index.js +22 -3
  87. package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
  88. package/dist/subscriptions/lifecycleMessages.js +38 -0
  89. package/dist/subscriptions/paymentOutcomes.js +1 -1
  90. package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
  91. package/dist/subscriptions/postgresCommandStore.js +172 -0
  92. package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
  93. package/dist/subscriptions/postgresRetryStore.js +11 -0
  94. package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
  95. package/dist/subscriptions/postgresStockWaitStore.js +174 -0
  96. package/dist/subscriptions/services.d.ts +31 -0
  97. package/dist/subscriptions/services.js +15 -0
  98. package/package.json +3 -3
  99. package/release-gates.json +21 -7
  100. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  101. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  102. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  103. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  104. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  105. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  106. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  107. package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
  108. package/sql/20261024180000_subscription_stock_wait.sql +330 -0
  109. package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
  110. package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
  111. package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
  112. package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
  113. package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
  114. package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
  115. package/src/composition/index.ts +1 -0
  116. package/src/composition/subscriptionContribution.ts +25 -0
  117. package/src/fulfillment/contracts/dispatch.ts +4 -0
  118. package/src/fulfillment/dispatchProjection.ts +4 -2
  119. package/src/fulfillment/omnipack/client.ts +13 -6
  120. package/src/fulfillment/omnipack/index.ts +5 -2
  121. package/src/fulfillment/providerDispatchWorker.ts +14 -1
  122. package/src/inventory/index.ts +2 -0
  123. package/src/inventory/postgresStockAvailability.ts +54 -0
  124. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  125. package/src/invoicing/accountingLineAllocation.ts +69 -0
  126. package/src/invoicing/adapterConformance.ts +249 -0
  127. package/src/invoicing/artifacts.ts +19 -0
  128. package/src/invoicing/bindings.ts +80 -0
  129. package/src/invoicing/channelPolicy.ts +50 -0
  130. package/src/invoicing/continuity.ts +15 -0
  131. package/src/invoicing/contracts/facts.ts +12 -1
  132. package/src/invoicing/contracts/host.ts +3 -4
  133. package/src/invoicing/contracts/index.ts +1 -1
  134. package/src/invoicing/contracts/store.ts +11 -7
  135. package/src/invoicing/contribution.ts +28 -0
  136. package/src/invoicing/correction.ts +56 -0
  137. package/src/invoicing/delivery.ts +122 -0
  138. package/src/invoicing/documentHistory.ts +301 -0
  139. package/src/invoicing/documentSync.ts +51 -0
  140. package/src/invoicing/dutyCollector.ts +31 -0
  141. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  142. package/src/invoicing/handoff.ts +71 -0
  143. package/src/invoicing/index.ts +38 -0
  144. package/src/invoicing/issue.ts +64 -0
  145. package/src/invoicing/migrations.ts +25 -0
  146. package/src/invoicing/nativeUnsupported.ts +24 -0
  147. package/src/invoicing/paymentReadback.ts +70 -0
  148. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  149. package/src/invoicing/readModel.ts +80 -0
  150. package/src/invoicing/readback.ts +26 -0
  151. package/src/invoicing/reversal.ts +34 -0
  152. package/src/invoicing/sellerRevision.ts +20 -0
  153. package/src/invoicing/settlementSync.ts +37 -0
  154. package/src/invoicing/snapshot.ts +192 -0
  155. package/src/subscriptions/commandExamples.ts +19 -0
  156. package/src/subscriptions/contracts/commands.ts +35 -2
  157. package/src/subscriptions/contracts/index.ts +2 -2
  158. package/src/subscriptions/contracts/stockWait.ts +14 -2
  159. package/src/subscriptions/index.ts +30 -3
  160. package/src/subscriptions/lifecycleMessages.ts +37 -0
  161. package/src/subscriptions/paymentOutcomes.ts +1 -1
  162. package/src/subscriptions/postgresCommandStore.ts +183 -0
  163. package/src/subscriptions/postgresRetryStore.ts +11 -0
  164. package/src/subscriptions/postgresStockWaitStore.ts +170 -0
  165. package/src/subscriptions/services.ts +21 -0
@@ -0,0 +1,333 @@
1
+ -- Public platform accounting rail, second forward: the payout. Two relations, one mutation,
2
+ -- one derived verdict and one deferred invariant -- the way a deployment says "the transport
3
+ -- that took our customers' money has now moved some of it to us, and here is which payments it
4
+ -- was for".
5
+ --
6
+ -- WHAT THIS FORWARD SHIPS, AND WHY IT IS NOT THE SETTLEMENT RAIL. The settlement forward gave
7
+ -- this kernel the customer side of money: an order declares a total, an intent asks for it, one
8
+ -- accepted settlement marks the order paid. None of that says anything about the money
9
+ -- ARRIVING. Between "the customer paid" and "we were paid" sits a batch the transport assembles
10
+ -- on its own schedule, out of many payments, minus a fee it decides, landing as one amount. A
11
+ -- deployment that cannot record that batch cannot answer the only question an accountant asks
12
+ -- of a shop -- does the money that arrived equal the money the orders say was charged -- and
13
+ -- until this forward the second bundle could not express the question, let alone the answer.
14
+ --
15
+ -- THE BATCH IS EVIDENCE, NOT AN INSTRUCTION. Nothing below moves money, asks anyone for money,
16
+ -- or talks to anything. Every row here is a record of something that already happened
17
+ -- elsewhere, which is why there is no state machine, no retry, no schedule and no queue: a
18
+ -- payout is observed, and the only thing this kernel decides is whether what was observed
19
+ -- agrees with itself.
20
+ --
21
+ -- WHERE THE SEMANTICS COME FROM, AND EVERY DEPARTURE. The behaviour was derived from the
22
+ -- managed chain by reading its settlement-sync plane end to end -- the batch upsert, the item
23
+ -- insert that replays on a constraint, the totals recomputed from the items, and the batch
24
+ -- verdict derived from the worst item it holds. Six departures, each named, because a silent
25
+ -- one is how a kernel and its origin quietly stop being the same machine:
26
+ --
27
+ -- 1. THE BATCH TOTALS ARE A DEFERRED CONSTRAINT, NOT A CONVENTION. Upstream recomputes the
28
+ -- three totals inside the mutation and nothing defends them afterwards: one hand-written
29
+ -- INSERT into the item relation leaves a batch whose declared amount is a fiction, and
30
+ -- nothing anywhere notices. Here the agreement between a batch and its items is a
31
+ -- constraint trigger on BOTH sides, deferred to the end of the transaction that touched
32
+ -- either -- the same device the document rail's position sum uses, for the same reason.
33
+ -- 2. CURRENCY LIVES ON THE BATCH, NOT ON EVERY ITEM. Upstream carries it on both and checks
34
+ -- neither against the other, so one batch can hold items in disagreeing currencies. A
35
+ -- payout is one wire in one currency; that is the fact, so it is stored once and a second
36
+ -- item that disagrees with it is refused by name. As in the settlement forward, currency
37
+ -- is a COLUMN WITH A CHECKED SHAPE and never a value: three characters, whichever three
38
+ -- the deployment declared.
39
+ -- 3. MINOR UNITS, NOT CENTS, matching every forward this catalogue carries. `cents` is the
40
+ -- subdivision of two particular currencies.
41
+ -- 4. EACH REFUSAL HAS ITS OWN NAME. Upstream raises one error for a key that is too short, a
42
+ -- negative amount, a status it does not recognise and arithmetic that does not add up.
43
+ -- Four different mistakes answered with one word is a diagnostic this kernel refuses to
44
+ -- inherit, so each is named below. The cross-bundle harness therefore compares exactly one
45
+ -- refusal -- the arithmetic one -- and the rest are proved on this bundle alone.
46
+ -- 5. THE LINK TO A DOCUMENT AND AN ORDER IS OPTIONAL AND REAL. Upstream declares the same two
47
+ -- keys and its only shipped caller passes NULL for both in production, so on that side
48
+ -- they are columns nothing fills. They are kept here, as `ON DELETE SET NULL` keys onto
49
+ -- relations earlier forwards published, because "settled against the ledger" is the whole
50
+ -- point of the capability -- but they are optional, because a transport that reports a
51
+ -- payment this deployment cannot resolve must still be recordable. A payout that cannot be
52
+ -- matched is EXACTLY the row an operator needs to see.
53
+ -- 6. NO `SECURITY DEFINER`, NO `GRANT`/`REVOKE`, NO ROW-LEVEL SECURITY, matching every forward
54
+ -- this catalogue carries and unlike upstream, where the same function is `SECURITY
55
+ -- DEFINER` behind four policies. This kernel creates zero roles; the functions below are
56
+ -- not a security boundary and the host application is what authenticates. `SET search_path`
57
+ -- is kept, because that one is a correctness property. Every constraint is named, and
58
+ -- `CREATE` is unconditional, because a manifest-ordered forward runs once against a known
59
+ -- prefix and `IF NOT EXISTS` would hide the drift the migration ledger exists to catch.
60
+ --
61
+ -- WHAT THIS FORWARD DELIBERATELY DOES NOT SHIP, enumerated so nobody has to infer it:
62
+ -- * no transport. Not a name, not an enum, not a branch keyed on who paid out. `provider_kind`
63
+ -- is an opaque label this kernel stores, compares for equality, and never parses;
64
+ -- * no protocol, no credential, no polling and no inbound event relation. Whoever observed the
65
+ -- payout is the caller's problem;
66
+ -- * no bank account, no payout schedule, no fee model, no tax treatment of a fee;
67
+ -- * no reconciliation verdict about an ORDER. This forward says whether a batch agrees with
68
+ -- its own items. Whether a shop's takings agree with its documents is a report, and a report
69
+ -- is not platform data;
70
+ -- * no runtime binding. Not one line of the application is rewired onto these relations by
71
+ -- this forward; the falsifier is its own cross-bundle parity harness.
72
+
73
+ CREATE TABLE public.accounting_payout_batches (
74
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
75
+ provider_kind text NOT NULL,
76
+ provider_batch_id text NOT NULL,
77
+ status text NOT NULL DEFAULT 'observed',
78
+ currency text NOT NULL,
79
+ gross_minor bigint NOT NULL DEFAULT 0,
80
+ fee_minor bigint NOT NULL DEFAULT 0,
81
+ net_minor bigint NOT NULL DEFAULT 0,
82
+ settled_at timestamptz,
83
+ created_at timestamptz NOT NULL DEFAULT now(),
84
+ updated_at timestamptz NOT NULL DEFAULT now(),
85
+ evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
86
+ CONSTRAINT accounting_payout_batches_reference_key UNIQUE (provider_kind, provider_batch_id),
87
+ CONSTRAINT accounting_payout_batches_provider_kind_nonempty_check CHECK (btrim(provider_kind) <> ''),
88
+ CONSTRAINT accounting_payout_batches_provider_batch_id_nonempty_check CHECK (btrim(provider_batch_id) <> ''),
89
+ -- Departure 2: a shape, never a value.
90
+ CONSTRAINT accounting_payout_batches_currency_shape_check CHECK (char_length(currency) = 3),
91
+ -- The four verdicts a batch can carry. Three of them are the observation still being
92
+ -- incomplete in three different ways, and only one of them is "this agrees".
93
+ CONSTRAINT accounting_payout_batches_status_check CHECK (status IN (
94
+ 'observed', 'reconciled', 'mismatch', 'bank_pending'
95
+ )),
96
+ CONSTRAINT accounting_payout_batches_amounts_check CHECK (
97
+ gross_minor >= 0 AND fee_minor >= 0 AND net_minor >= 0
98
+ ),
99
+ CONSTRAINT accounting_payout_batches_net_check CHECK (net_minor = gross_minor - fee_minor)
100
+ );
101
+
102
+ CREATE TABLE public.accounting_payout_items (
103
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
104
+ batch_id uuid NOT NULL REFERENCES public.accounting_payout_batches(id) ON DELETE CASCADE,
105
+ provider_payment_id text NOT NULL,
106
+ -- Departure 5: optional on purpose, and the reason is in the header.
107
+ document_id uuid REFERENCES public.accounting_documents(id) ON DELETE SET NULL,
108
+ order_id uuid REFERENCES public.commerce_orders(id) ON DELETE SET NULL,
109
+ status text NOT NULL,
110
+ gross_minor bigint NOT NULL,
111
+ fee_minor bigint NOT NULL,
112
+ net_minor bigint NOT NULL,
113
+ settled_at timestamptz,
114
+ created_at timestamptz NOT NULL DEFAULT now(),
115
+ evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
116
+ CONSTRAINT accounting_payout_items_reference_key UNIQUE (batch_id, provider_payment_id),
117
+ CONSTRAINT accounting_payout_items_provider_payment_id_nonempty_check
118
+ CHECK (btrim(provider_payment_id) <> ''),
119
+ CONSTRAINT accounting_payout_items_status_check CHECK (status IN (
120
+ 'matched', 'unmatched', 'mismatch', 'bank_pending'
121
+ )),
122
+ CONSTRAINT accounting_payout_items_amounts_check CHECK (
123
+ gross_minor >= 0 AND fee_minor >= 0 AND net_minor >= 0
124
+ ),
125
+ CONSTRAINT accounting_payout_items_net_check CHECK (net_minor = gross_minor - fee_minor)
126
+ );
127
+
128
+ -- The lookup an operator actually performs: which payout paid for this document. Partial,
129
+ -- because the majority of items on a young deployment carry no link at all.
130
+ CREATE INDEX idx_accounting_payout_items_document
131
+ ON public.accounting_payout_items (document_id)
132
+ WHERE document_id IS NOT NULL;
133
+
134
+ -- Departure 1. The batch's three totals are the sums of its items, checked at the end of the
135
+ -- transaction that touched either side, so a batch and its items can only ever be committed in
136
+ -- agreement. An argument check inside the mutation would be satisfied once and then bypassed by
137
+ -- the next write to the item relation.
138
+ CREATE FUNCTION public.accounting_payout_assert_batch_totals()
139
+ RETURNS trigger
140
+ LANGUAGE plpgsql
141
+ SET search_path = public, pg_catalog
142
+ AS $$
143
+ DECLARE
144
+ v_batch_id uuid;
145
+ v_batch public.accounting_payout_batches%ROWTYPE;
146
+ v_gross bigint;
147
+ v_fee bigint;
148
+ v_net bigint;
149
+ BEGIN
150
+ -- Branching statements rather than one CASE expression, for the reason the document rail's
151
+ -- position-sum trigger records: the two row types this trigger can see differ.
152
+ IF TG_TABLE_NAME = 'accounting_payout_batches' THEN
153
+ v_batch_id := NEW.id;
154
+ ELSIF TG_OP = 'DELETE' THEN
155
+ v_batch_id := OLD.batch_id;
156
+ ELSE
157
+ v_batch_id := NEW.batch_id;
158
+ END IF;
159
+
160
+ SELECT * INTO v_batch FROM public.accounting_payout_batches WHERE id = v_batch_id;
161
+ -- The batch is gone: a cascade took it, and there is nothing left to be consistent with.
162
+ IF NOT FOUND THEN
163
+ RETURN NULL;
164
+ END IF;
165
+
166
+ SELECT COALESCE(sum(gross_minor), 0), COALESCE(sum(fee_minor), 0), COALESCE(sum(net_minor), 0)
167
+ INTO v_gross, v_fee, v_net
168
+ FROM public.accounting_payout_items
169
+ WHERE batch_id = v_batch_id;
170
+
171
+ IF v_batch.gross_minor IS DISTINCT FROM v_gross
172
+ OR v_batch.fee_minor IS DISTINCT FROM v_fee
173
+ OR v_batch.net_minor IS DISTINCT FROM v_net THEN
174
+ RAISE EXCEPTION 'accounting_payout_batch_total_mismatch' USING ERRCODE = '23514',
175
+ DETAIL = format('batch %s declares %s/%s/%s and its items sum to %s/%s/%s',
176
+ v_batch_id, v_batch.gross_minor, v_batch.fee_minor, v_batch.net_minor,
177
+ v_gross, v_fee, v_net);
178
+ END IF;
179
+
180
+ RETURN NULL;
181
+ END;
182
+ $$;
183
+
184
+ CREATE CONSTRAINT TRIGGER trg_accounting_payout_batches_totals
185
+ AFTER INSERT OR UPDATE ON public.accounting_payout_batches
186
+ DEFERRABLE INITIALLY DEFERRED
187
+ FOR EACH ROW
188
+ EXECUTE FUNCTION public.accounting_payout_assert_batch_totals();
189
+
190
+ CREATE CONSTRAINT TRIGGER trg_accounting_payout_items_totals
191
+ AFTER INSERT OR UPDATE OR DELETE ON public.accounting_payout_items
192
+ DEFERRABLE INITIALLY DEFERRED
193
+ FOR EACH ROW
194
+ EXECUTE FUNCTION public.accounting_payout_assert_batch_totals();
195
+
196
+ -- The verdict, in one place, so the mutation and any later reader cannot disagree about what a
197
+ -- batch of a given shape means. Worst item wins: an item nobody could match, or one whose
198
+ -- amounts disagree with the payment they claim, makes the whole batch a mismatch; an item the
199
+ -- transport says it has not wired yet holds the batch open; only a batch whose every item is
200
+ -- matched has reconciled.
201
+ CREATE FUNCTION public.accounting_payout_batch_verdict(p_batch_id uuid)
202
+ RETURNS text
203
+ LANGUAGE sql
204
+ STABLE
205
+ SET search_path = public, pg_catalog
206
+ AS $$
207
+ SELECT CASE
208
+ WHEN count(*) = 0 THEN 'observed'
209
+ WHEN count(*) FILTER (WHERE status IN ('mismatch', 'unmatched')) > 0 THEN 'mismatch'
210
+ WHEN count(*) FILTER (WHERE status = 'bank_pending') > 0 THEN 'bank_pending'
211
+ ELSE 'reconciled'
212
+ END
213
+ FROM public.accounting_payout_items
214
+ WHERE batch_id = p_batch_id;
215
+ $$;
216
+
217
+ -- The one mutation. It records a single line of a payout, creating the batch that line belongs
218
+ -- to on first contact, and answers with the batch's verdict as of this observation.
219
+ CREATE FUNCTION public.accounting_payout_record_item(
220
+ p_idempotency_key text,
221
+ p_provider_kind text,
222
+ p_provider_batch_id text,
223
+ p_provider_payment_id text,
224
+ p_currency text,
225
+ p_status text,
226
+ p_gross_minor bigint,
227
+ p_fee_minor bigint,
228
+ p_net_minor bigint,
229
+ p_document_id uuid DEFAULT NULL,
230
+ p_order_id uuid DEFAULT NULL,
231
+ p_settled_at timestamptz DEFAULT NULL,
232
+ p_evidence jsonb DEFAULT '{}'::jsonb,
233
+ p_observed_at timestamptz DEFAULT now()
234
+ )
235
+ RETURNS jsonb
236
+ LANGUAGE plpgsql
237
+ SET search_path = public, pg_catalog
238
+ AS $$
239
+ DECLARE
240
+ v_batch public.accounting_payout_batches%ROWTYPE;
241
+ v_item public.accounting_payout_items%ROWTYPE;
242
+ v_replayed boolean := false;
243
+ v_verdict text;
244
+ BEGIN
245
+ IF p_idempotency_key IS NULL OR length(btrim(p_idempotency_key)) < 8
246
+ OR p_provider_kind IS NULL OR btrim(p_provider_kind) = ''
247
+ OR p_provider_batch_id IS NULL OR btrim(p_provider_batch_id) = ''
248
+ OR p_provider_payment_id IS NULL OR btrim(p_provider_payment_id) = ''
249
+ OR p_currency IS NULL OR char_length(p_currency) <> 3
250
+ OR p_gross_minor IS NULL OR p_fee_minor IS NULL OR p_net_minor IS NULL THEN
251
+ RAISE EXCEPTION 'accounting_payout_record_invalid_input' USING ERRCODE = '22023';
252
+ END IF;
253
+
254
+ -- Departure 4: four mistakes, four names.
255
+ IF p_status IS NULL OR p_status NOT IN ('matched', 'unmatched', 'mismatch', 'bank_pending') THEN
256
+ RAISE EXCEPTION 'accounting_payout_record_status_invalid' USING ERRCODE = '22023';
257
+ END IF;
258
+
259
+ IF p_gross_minor < 0 OR p_fee_minor < 0 OR p_net_minor < 0 THEN
260
+ RAISE EXCEPTION 'accounting_payout_record_amount_negative' USING ERRCODE = '22023';
261
+ END IF;
262
+
263
+ IF p_net_minor <> p_gross_minor - p_fee_minor THEN
264
+ RAISE EXCEPTION 'accounting_payout_record_net_mismatch' USING ERRCODE = '22023',
265
+ DETAIL = format('item %s declares %s net of %s and %s', p_provider_payment_id,
266
+ p_net_minor, p_gross_minor, p_fee_minor);
267
+ END IF;
268
+
269
+ INSERT INTO public.accounting_payout_batches (
270
+ provider_kind, provider_batch_id, currency, settled_at, created_at, updated_at, evidence
271
+ )
272
+ VALUES (
273
+ p_provider_kind, p_provider_batch_id, p_currency, p_settled_at, p_observed_at, p_observed_at,
274
+ COALESCE(p_evidence, '{}'::jsonb)
275
+ )
276
+ ON CONFLICT ON CONSTRAINT accounting_payout_batches_reference_key DO UPDATE
277
+ SET updated_at = p_observed_at,
278
+ settled_at = COALESCE(EXCLUDED.settled_at, accounting_payout_batches.settled_at),
279
+ evidence = accounting_payout_batches.evidence || EXCLUDED.evidence
280
+ RETURNING * INTO v_batch;
281
+
282
+ -- Departure 2. Equality, not coercion: a batch that already knows what it was paid in is not
283
+ -- quietly re-denominated by the next line somebody reports against it.
284
+ IF v_batch.currency <> p_currency THEN
285
+ RAISE EXCEPTION 'accounting_payout_batch_currency_conflict' USING ERRCODE = '23514',
286
+ DETAIL = format('batch %s was observed in %s', v_batch.id, v_batch.currency);
287
+ END IF;
288
+
289
+ INSERT INTO public.accounting_payout_items (
290
+ batch_id, provider_payment_id, document_id, order_id, status,
291
+ gross_minor, fee_minor, net_minor, settled_at, created_at, evidence
292
+ )
293
+ VALUES (
294
+ v_batch.id, p_provider_payment_id, p_document_id, p_order_id, p_status,
295
+ p_gross_minor, p_fee_minor, p_net_minor, p_settled_at, p_observed_at,
296
+ COALESCE(p_evidence, '{}'::jsonb) || jsonb_build_object('recordIdempotencyKey', p_idempotency_key)
297
+ )
298
+ ON CONFLICT ON CONSTRAINT accounting_payout_items_reference_key DO NOTHING
299
+ RETURNING * INTO v_item;
300
+
301
+ IF v_item.id IS NULL THEN
302
+ -- Replay is a constraint, not a timestamp comparison: the second caller reads the row the
303
+ -- first one wrote and is told so.
304
+ v_replayed := true;
305
+ SELECT * INTO v_item
306
+ FROM public.accounting_payout_items
307
+ WHERE batch_id = v_batch.id AND provider_payment_id = p_provider_payment_id;
308
+ END IF;
309
+
310
+ UPDATE public.accounting_payout_batches
311
+ SET updated_at = p_observed_at,
312
+ gross_minor = totals.gross_minor,
313
+ fee_minor = totals.fee_minor,
314
+ net_minor = totals.net_minor,
315
+ status = public.accounting_payout_batch_verdict(v_batch.id)
316
+ FROM (
317
+ SELECT COALESCE(sum(gross_minor), 0) AS gross_minor,
318
+ COALESCE(sum(fee_minor), 0) AS fee_minor,
319
+ COALESCE(sum(net_minor), 0) AS net_minor
320
+ FROM public.accounting_payout_items
321
+ WHERE batch_id = v_batch.id
322
+ ) AS totals
323
+ WHERE id = v_batch.id
324
+ RETURNING status INTO v_verdict;
325
+
326
+ RETURN jsonb_build_object(
327
+ 'batchId', v_batch.id,
328
+ 'itemId', v_item.id,
329
+ 'status', v_verdict,
330
+ 'replayed', v_replayed
331
+ );
332
+ END;
333
+ $$;