@fantastic.dev/better-stripe 0.11.0 → 0.13.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 +30 -0
- package/README.md +267 -2
- package/dist/client/billing/balanceFunding.d.ts +36 -0
- package/dist/client/billing/balanceFunding.d.ts.map +1 -0
- package/dist/client/billing/balanceFunding.js +107 -0
- package/dist/client/billing/balanceFunding.js.map +1 -0
- package/dist/client/billing/invoicePreview.d.ts +28 -0
- package/dist/client/billing/invoicePreview.d.ts.map +1 -0
- package/dist/client/billing/invoicePreview.js +56 -0
- package/dist/client/billing/invoicePreview.js.map +1 -0
- package/dist/client/charge-currency-format.d.ts +3 -0
- package/dist/client/charge-currency-format.d.ts.map +1 -0
- package/dist/client/charge-currency-format.js +22 -0
- package/dist/client/charge-currency-format.js.map +1 -0
- package/dist/client/charge-currency.d.ts +14 -0
- package/dist/client/charge-currency.d.ts.map +1 -0
- package/dist/client/charge-currency.js +85 -0
- package/dist/client/charge-currency.js.map +1 -0
- package/dist/client/costRecovery-invoices.d.ts +38 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +105 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +156 -0
- package/dist/client/costRecovery.d.ts.map +1 -0
- package/dist/client/costRecovery.js +117 -0
- package/dist/client/costRecovery.js.map +1 -0
- package/dist/client/index.d.ts +28 -66
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +25 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/products/prices.d.ts +3 -2
- package/dist/client/products/prices.d.ts.map +1 -1
- package/dist/client/products/prices.js +0 -3
- package/dist/client/products/prices.js.map +1 -1
- package/dist/client/products/products.d.ts +3 -2
- package/dist/client/products/products.d.ts.map +1 -1
- package/dist/client/products/products.js +0 -3
- package/dist/client/products/products.js.map +1 -1
- package/dist/client/settlement/processingCosts.d.ts +56 -0
- package/dist/client/settlement/processingCosts.d.ts.map +1 -0
- package/dist/client/settlement/processingCosts.js +173 -0
- package/dist/client/settlement/processingCosts.js.map +1 -0
- package/dist/client/utils/webhookEndpoints.d.ts +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +1 -0
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +322 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +68 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -0
- package/dist/component/costRecovery/claims.js +92 -0
- package/dist/component/costRecovery/claims.js.map +1 -0
- package/dist/component/costRecovery/completion.d.ts +39 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -0
- package/dist/component/costRecovery/completion.js +69 -0
- package/dist/component/costRecovery/completion.js.map +1 -0
- package/dist/component/costRecovery/helpers.d.ts +68 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -0
- package/dist/component/costRecovery/helpers.js +71 -0
- package/dist/component/costRecovery/helpers.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +210 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -0
- package/dist/component/costRecovery/mutations.js +93 -0
- package/dist/component/costRecovery/mutations.js.map +1 -0
- package/dist/component/costRecovery/queries.d.ts +108 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -0
- package/dist/component/costRecovery/queries.js +73 -0
- package/dist/component/costRecovery/queries.js.map +1 -0
- package/dist/component/costRecovery/reservations.d.ts +95 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -0
- package/dist/component/costRecovery/reservations.js +134 -0
- package/dist/component/costRecovery/reservations.js.map +1 -0
- package/dist/component/costRecovery/schema.d.ts +382 -0
- package/dist/component/costRecovery/schema.d.ts.map +1 -0
- package/dist/component/costRecovery/schema.js +115 -0
- package/dist/component/costRecovery/schema.js.map +1 -0
- package/dist/component/schema.d.ts +135 -0
- package/dist/component/schema.js +5 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/PriceCard.d.ts +3 -1
- package/dist/react/components/PriceCard.d.ts.map +1 -1
- package/dist/react/components/PriceCard.js +2 -2
- package/dist/react/components/PriceCard.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/price-helpers.d.ts +4 -6
- package/dist/react/lib/price-helpers.d.ts.map +1 -1
- package/dist/react/lib/price-helpers.js +16 -9
- package/dist/react/lib/price-helpers.js.map +1 -1
- package/dist/react/workflows/Collections.d.ts.map +1 -1
- package/dist/react/workflows/Collections.js +2 -1
- package/dist/react/workflows/Collections.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.js +12 -9
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
- package/dist/react/workflows/Refunds.d.ts +14 -3
- package/dist/react/workflows/Refunds.d.ts.map +1 -1
- package/dist/react/workflows/Refunds.js +58 -16
- package/dist/react/workflows/Refunds.js.map +1 -1
- package/dist/react/workflows/index.d.ts +3 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -1
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/refund-preview.d.ts +16 -0
- package/dist/react/workflows/refund-preview.d.ts.map +1 -0
- package/dist/react/workflows/refund-preview.js +38 -0
- package/dist/react/workflows/refund-preview.js.map +1 -0
- package/docs/react-workflows.md +167 -1
- package/package.json +2 -2
- package/src/client/billing/balanceFunding.test.ts +257 -0
- package/src/client/billing/balanceFunding.ts +183 -0
- package/src/client/billing/balanceFundingTransport.test.ts +89 -0
- package/src/client/billing/invoicePreview.test.ts +123 -0
- package/src/client/billing/invoicePreview.ts +94 -0
- package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
- package/src/client/charge-currency-format.ts +29 -0
- package/src/client/charge-currency.test.ts +113 -0
- package/src/client/charge-currency.ts +119 -0
- package/src/client/costRecovery-invoices.test.ts +401 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +144 -0
- package/src/client/costRecovery.test.ts +76 -0
- package/src/client/costRecovery.ts +254 -0
- package/src/client/index.ts +76 -75
- package/src/client/products/prices.ts +13 -11
- package/src/client/products/products.ts +10 -10
- package/src/client/settlement/processingCosts.test.ts +349 -0
- package/src/client/settlement/processingCosts.ts +315 -0
- package/src/client/settlement/processingCostsTransport.test.ts +135 -0
- package/src/client/utils/webhookEndpoints.test.ts +5 -0
- package/src/client/utils/webhookEndpoints.ts +1 -0
- package/src/client/webhooks/hooks.test.ts +9 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
- package/src/client/webhooks/processors.ts +1 -0
- package/src/component/_generated/api.ts +12 -0
- package/src/component/_generated/component.ts +407 -0
- package/src/component/cost-recovery.test.ts +344 -0
- package/src/component/costRecovery/claims.ts +119 -0
- package/src/component/costRecovery/completion.ts +92 -0
- package/src/component/costRecovery/helpers.ts +83 -0
- package/src/component/costRecovery/mutations.ts +115 -0
- package/src/component/costRecovery/queries.ts +88 -0
- package/src/component/costRecovery/reservations.ts +164 -0
- package/src/component/costRecovery/schema.ts +135 -0
- package/src/component/schema.ts +10 -0
- package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
- package/src/react/components/PriceCard.render.test.tsx +37 -0
- package/src/react/components/PriceCard.tsx +9 -1
- package/src/react/index.test.ts +5 -0
- package/src/react/index.ts +7 -0
- package/src/react/lib/price-helpers.test.ts +15 -2
- package/src/react/lib/price-helpers.ts +25 -8
- package/src/react/workflows/Billing.test.tsx +1 -1
- package/src/react/workflows/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
- package/src/react/workflows/PlanPricingEditor.tsx +26 -10
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +8 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- package/src/react/workflows/refund-preview.ts +56 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
7
|
+
const m = (name: string) =>
|
|
8
|
+
makeFunctionReference<"mutation">(`costRecovery/${name}`);
|
|
9
|
+
const q = (name: string) =>
|
|
10
|
+
makeFunctionReference<"query">(`costRecovery/queries:${name}`);
|
|
11
|
+
const scope = { accountId: "acct_store", scopeKey: "store", currency: "usd" };
|
|
12
|
+
const observation = (claimKey: string, amountDue: number, revision = 1) => ({
|
|
13
|
+
...scope,
|
|
14
|
+
claimKey,
|
|
15
|
+
sourceChargeId: `ch_${claimKey}`,
|
|
16
|
+
sourceGross: 10000,
|
|
17
|
+
sourceProcessingFee: 320,
|
|
18
|
+
amountDue,
|
|
19
|
+
revision,
|
|
20
|
+
});
|
|
21
|
+
const reserve = (operationKey: string, groupKey = "group", limit = 100) => ({
|
|
22
|
+
...scope,
|
|
23
|
+
groupKey,
|
|
24
|
+
operationKey,
|
|
25
|
+
sourceKind: "settlement" as const,
|
|
26
|
+
sourceId: "ch_funding",
|
|
27
|
+
sourceGross: 10000,
|
|
28
|
+
sourceProcessingFee: 320,
|
|
29
|
+
maxDebit: 10000,
|
|
30
|
+
maxCredit: 10000,
|
|
31
|
+
limit,
|
|
32
|
+
});
|
|
33
|
+
it("preserves identity, ignores stale observations and rejects conflicting replays", async () => {
|
|
34
|
+
const t = convexTest(schema, modules);
|
|
35
|
+
const first = await t.mutation(
|
|
36
|
+
m("mutations:record"),
|
|
37
|
+
observation("a", 500, 2),
|
|
38
|
+
);
|
|
39
|
+
expect(
|
|
40
|
+
await t.mutation(m("mutations:record"), observation("a", 900, 1)),
|
|
41
|
+
).toEqual(first);
|
|
42
|
+
expect(
|
|
43
|
+
await t.mutation(m("mutations:record"), observation("a", 500, 2)),
|
|
44
|
+
).toEqual(first);
|
|
45
|
+
await expect(
|
|
46
|
+
t.mutation(m("mutations:record"), observation("a", 800, 2)),
|
|
47
|
+
).rejects.toThrow(/revision/);
|
|
48
|
+
await expect(
|
|
49
|
+
t.mutation(m("mutations:record"), {
|
|
50
|
+
...observation("a", 500, 3),
|
|
51
|
+
accountId: "acct_other",
|
|
52
|
+
}),
|
|
53
|
+
).rejects.toThrow(/identity/);
|
|
54
|
+
await expect(
|
|
55
|
+
t.mutation(m("mutations:record"), {
|
|
56
|
+
...observation("a", 500, 3),
|
|
57
|
+
sourceProcessingFee: 0,
|
|
58
|
+
}),
|
|
59
|
+
).rejects.toThrow(/identity/);
|
|
60
|
+
});
|
|
61
|
+
it("nets unseen credits and isolates currencies/scopes beyond one history page", async () => {
|
|
62
|
+
const t = convexTest(schema, modules);
|
|
63
|
+
for (let i = 0; i < 125; i++)
|
|
64
|
+
await t.mutation(m("mutations:record"), observation(`a${i}`, 10));
|
|
65
|
+
await t.mutation(m("mutations:record"), observation("credit", -1200));
|
|
66
|
+
await t.mutation(m("mutations:record"), {
|
|
67
|
+
...observation("eur", 9000),
|
|
68
|
+
currency: "eur",
|
|
69
|
+
});
|
|
70
|
+
await t.mutation(m("mutations:record"), {
|
|
71
|
+
...observation("other", 9000),
|
|
72
|
+
scopeKey: "other",
|
|
73
|
+
});
|
|
74
|
+
const r = await t.mutation(m("mutations:reserve"), reserve("batch-1"));
|
|
75
|
+
expect(r.reservation.amount).toBe(50);
|
|
76
|
+
expect((await t.query(q("balance"), scope)).outstanding).toBe(0);
|
|
77
|
+
const claims: unknown[] = [];
|
|
78
|
+
let cursor: string | null = null;
|
|
79
|
+
do {
|
|
80
|
+
const page: { page: unknown[]; isDone: boolean; continueCursor: string } =
|
|
81
|
+
await t.query(q("listClaims"), {
|
|
82
|
+
...scope,
|
|
83
|
+
paginationOpts: { numItems: 20, cursor },
|
|
84
|
+
});
|
|
85
|
+
claims.push(...page.page);
|
|
86
|
+
cursor = page.isDone ? null : page.continueCursor;
|
|
87
|
+
} while (cursor);
|
|
88
|
+
expect(claims).toHaveLength(126);
|
|
89
|
+
});
|
|
90
|
+
it("reserves in bounded batches and seals only a fully prepared group", async () => {
|
|
91
|
+
const t = convexTest(schema, modules);
|
|
92
|
+
for (let i = 0; i < 110; i++)
|
|
93
|
+
await t.mutation(m("mutations:record"), observation(`a${i}`, 10));
|
|
94
|
+
const first = await t.mutation(m("mutations:reserve"), reserve("batch-1"));
|
|
95
|
+
expect(first.reservation.amount).toBe(1000);
|
|
96
|
+
await expect(
|
|
97
|
+
t.mutation(m("mutations:seal"), { groupKey: "group" }),
|
|
98
|
+
).rejects.toThrow(/batch/);
|
|
99
|
+
const second = await t.mutation(m("mutations:reserve"), reserve("batch-2"));
|
|
100
|
+
expect(second.reservation.amount).toBe(100);
|
|
101
|
+
expect(
|
|
102
|
+
(await t.mutation(m("mutations:seal"), { groupKey: "group" })).amount,
|
|
103
|
+
).toBe(1100);
|
|
104
|
+
await expect(
|
|
105
|
+
t.mutation(m("mutations:reserve"), reserve("batch-3")),
|
|
106
|
+
).rejects.toThrow(/sealed/);
|
|
107
|
+
const partial = await t.mutation(m("mutations:finish"), {
|
|
108
|
+
groupKey: "group",
|
|
109
|
+
outcome: "collect",
|
|
110
|
+
limit: 1,
|
|
111
|
+
});
|
|
112
|
+
expect(partial.isDone).toBe(false);
|
|
113
|
+
const done = await t.mutation(m("mutations:finish"), {
|
|
114
|
+
groupKey: "group",
|
|
115
|
+
outcome: "collect",
|
|
116
|
+
limit: 1,
|
|
117
|
+
});
|
|
118
|
+
expect(done.isDone).toBe(true);
|
|
119
|
+
expect(await t.query(q("balance"), scope)).toMatchObject({
|
|
120
|
+
collectedAmount: 1100,
|
|
121
|
+
reservedAmount: 0,
|
|
122
|
+
outstanding: 0,
|
|
123
|
+
});
|
|
124
|
+
expect(
|
|
125
|
+
(
|
|
126
|
+
await t.mutation(m("mutations:finish"), {
|
|
127
|
+
groupKey: "group",
|
|
128
|
+
outcome: "collect",
|
|
129
|
+
limit: 1,
|
|
130
|
+
})
|
|
131
|
+
).isDone,
|
|
132
|
+
).toBe(true);
|
|
133
|
+
await expect(
|
|
134
|
+
t.mutation(m("mutations:finish"), {
|
|
135
|
+
groupKey: "group",
|
|
136
|
+
outcome: "release",
|
|
137
|
+
limit: 1,
|
|
138
|
+
}),
|
|
139
|
+
).rejects.toThrow(/outcome/);
|
|
140
|
+
});
|
|
141
|
+
it("serializes provider boundaries and recovers the same reservation after a worker dies", async () => {
|
|
142
|
+
const t = convexTest(schema, modules);
|
|
143
|
+
await t.mutation(m("mutations:record"), observation("a", 500));
|
|
144
|
+
const first = await t.mutation(m("mutations:reserve"), reserve("same"));
|
|
145
|
+
expect(await t.mutation(m("mutations:reserve"), reserve("same"))).toEqual(
|
|
146
|
+
first,
|
|
147
|
+
);
|
|
148
|
+
await expect(
|
|
149
|
+
t.mutation(m("mutations:reserve"), reserve("other", "other-group")),
|
|
150
|
+
).rejects.toThrow(/pending/);
|
|
151
|
+
await t.mutation(m("mutations:seal"), { groupKey: "group" });
|
|
152
|
+
await t.mutation(m("mutations:finish"), {
|
|
153
|
+
groupKey: "group",
|
|
154
|
+
outcome: "release",
|
|
155
|
+
limit: 10,
|
|
156
|
+
});
|
|
157
|
+
expect(await t.query(q("balance"), scope)).toMatchObject({
|
|
158
|
+
outstanding: 500,
|
|
159
|
+
collectedAmount: 0,
|
|
160
|
+
reservedAmount: 0,
|
|
161
|
+
});
|
|
162
|
+
expect(
|
|
163
|
+
(await t.mutation(m("mutations:reserve"), reserve("next", "next-group")))
|
|
164
|
+
.reservation.amount,
|
|
165
|
+
).toBe(500);
|
|
166
|
+
});
|
|
167
|
+
it("handles source credits while reserved without making them spendable at a different boundary", async () => {
|
|
168
|
+
const t = convexTest(schema, modules);
|
|
169
|
+
await t.mutation(m("mutations:record"), observation("a", 500));
|
|
170
|
+
await t.mutation(m("mutations:reserve"), reserve("debit"));
|
|
171
|
+
await t.mutation(m("mutations:record"), observation("a", 0, 2));
|
|
172
|
+
await expect(
|
|
173
|
+
t.mutation(m("mutations:reserve"), reserve("credit", "elsewhere")),
|
|
174
|
+
).rejects.toThrow(/pending/);
|
|
175
|
+
const credit = await t.mutation(m("mutations:reserve"), reserve("offset"));
|
|
176
|
+
expect(credit.reservation.amount).toBe(-500);
|
|
177
|
+
expect(
|
|
178
|
+
(await t.mutation(m("mutations:seal"), { groupKey: "group" })).amount,
|
|
179
|
+
).toBe(0);
|
|
180
|
+
await t.mutation(m("mutations:finish"), {
|
|
181
|
+
groupKey: "group",
|
|
182
|
+
outcome: "collect",
|
|
183
|
+
limit: 10,
|
|
184
|
+
});
|
|
185
|
+
expect(await t.query(q("balance"), scope)).toMatchObject({
|
|
186
|
+
amountDue: 0,
|
|
187
|
+
collectedAmount: 0,
|
|
188
|
+
reservedAmount: 0,
|
|
189
|
+
outstanding: 0,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
it("restores signed recovered costs proportionally when the funding sale is returned", async () => {
|
|
193
|
+
const t = convexTest(schema, modules);
|
|
194
|
+
await t.mutation(m("mutations:record"), observation("a", 501));
|
|
195
|
+
await t.mutation(m("mutations:reserve"), reserve("debit"));
|
|
196
|
+
await t.mutation(m("mutations:seal"), { groupKey: "group" });
|
|
197
|
+
await t.mutation(m("mutations:finish"), {
|
|
198
|
+
groupKey: "group",
|
|
199
|
+
outcome: "collect",
|
|
200
|
+
limit: 10,
|
|
201
|
+
});
|
|
202
|
+
const rollback = { groupKey: "group", returnedPrincipal: 5000, revision: 1 };
|
|
203
|
+
expect(await t.mutation(m("mutations:rollback"), rollback)).toMatchObject({
|
|
204
|
+
amountDue: 251,
|
|
205
|
+
sourceGross: 10000,
|
|
206
|
+
sourceProcessingFee: 320,
|
|
207
|
+
});
|
|
208
|
+
await t.mutation(m("mutations:rollback"), rollback);
|
|
209
|
+
expect((await t.query(q("balance"), scope)).outstanding).toBe(251);
|
|
210
|
+
await t.mutation(m("mutations:rollback"), {
|
|
211
|
+
...rollback,
|
|
212
|
+
returnedPrincipal: 10000,
|
|
213
|
+
revision: 2,
|
|
214
|
+
});
|
|
215
|
+
expect((await t.query(q("balance"), scope)).outstanding).toBe(501);
|
|
216
|
+
await t.mutation(m("mutations:rollback"), {
|
|
217
|
+
...rollback,
|
|
218
|
+
returnedPrincipal: 0,
|
|
219
|
+
revision: 3,
|
|
220
|
+
});
|
|
221
|
+
expect((await t.query(q("balance"), scope)).outstanding).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
it("permits only one concurrent external recovery boundary", async () => {
|
|
224
|
+
const t = convexTest(schema, modules);
|
|
225
|
+
await t.mutation(m("mutations:record"), observation("race", 500));
|
|
226
|
+
const results = await Promise.allSettled([
|
|
227
|
+
t.mutation(m("mutations:reserve"), reserve("one", "one")),
|
|
228
|
+
t.mutation(m("mutations:reserve"), reserve("two", "two")),
|
|
229
|
+
]);
|
|
230
|
+
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
|
231
|
+
expect(results.filter((r) => r.status === "rejected")).toHaveLength(1);
|
|
232
|
+
expect(await t.query(q("balance"), scope)).toMatchObject({
|
|
233
|
+
reservedAmount: 500,
|
|
234
|
+
outstanding: 0,
|
|
235
|
+
collectedAmount: 0,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
it("holds a credit created after sealing until the original debit is resolved", async () => {
|
|
239
|
+
const t = convexTest(schema, modules);
|
|
240
|
+
await t.mutation(m("mutations:record"), observation("a", 500));
|
|
241
|
+
await t.mutation(m("mutations:reserve"), reserve("debit"));
|
|
242
|
+
await t.mutation(m("mutations:seal"), { groupKey: "group" });
|
|
243
|
+
await t.mutation(m("mutations:record"), observation("a", 0, 2));
|
|
244
|
+
await expect(
|
|
245
|
+
t.mutation(m("mutations:reserve"), reserve("credit", "credit")),
|
|
246
|
+
).rejects.toThrow(/pending/);
|
|
247
|
+
await t.mutation(m("mutations:finish"), {
|
|
248
|
+
groupKey: "group",
|
|
249
|
+
outcome: "collect",
|
|
250
|
+
});
|
|
251
|
+
expect(await t.query(q("balance"), scope)).toMatchObject({
|
|
252
|
+
collectedAmount: 500,
|
|
253
|
+
outstanding: -500,
|
|
254
|
+
});
|
|
255
|
+
expect(
|
|
256
|
+
(await t.mutation(m("mutations:reserve"), reserve("credit", "credit")))
|
|
257
|
+
.reservation.amount,
|
|
258
|
+
).toBe(-500);
|
|
259
|
+
await t.mutation(m("mutations:seal"), { groupKey: "credit" });
|
|
260
|
+
await t.mutation(m("mutations:finish"), {
|
|
261
|
+
groupKey: "credit",
|
|
262
|
+
outcome: "collect",
|
|
263
|
+
});
|
|
264
|
+
expect(await t.query(q("balance"), scope)).toMatchObject({
|
|
265
|
+
amountDue: 0,
|
|
266
|
+
collectedAmount: 0,
|
|
267
|
+
outstanding: 0,
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
it("enforces a group-wide budget across batches and rounds signed credit rollback symmetrically", async () => {
|
|
271
|
+
const t = convexTest(schema, modules);
|
|
272
|
+
for (let i = 0; i < 3; i++)
|
|
273
|
+
await t.mutation(m("mutations:record"), observation(`a${i}`, 10));
|
|
274
|
+
const opts = { ...reserve("batch1", "limited", 1), maxDebit: 15 };
|
|
275
|
+
expect(
|
|
276
|
+
(await t.mutation(m("mutations:reserve"), opts)).reservation.amount,
|
|
277
|
+
).toBe(10);
|
|
278
|
+
expect(
|
|
279
|
+
(
|
|
280
|
+
await t.mutation(m("mutations:reserve"), {
|
|
281
|
+
...opts,
|
|
282
|
+
operationKey: "batch2",
|
|
283
|
+
})
|
|
284
|
+
).reservation.amount,
|
|
285
|
+
).toBe(5);
|
|
286
|
+
await expect(
|
|
287
|
+
t.mutation(m("mutations:reserve"), { ...opts, maxDebit: 100 }),
|
|
288
|
+
).rejects.toThrow(/identity/);
|
|
289
|
+
await t.mutation(m("mutations:seal"), { groupKey: "limited" });
|
|
290
|
+
await t.mutation(m("mutations:finish"), {
|
|
291
|
+
groupKey: "limited",
|
|
292
|
+
outcome: "collect",
|
|
293
|
+
});
|
|
294
|
+
expect((await t.query(q("balance"), scope)).outstanding).toBe(15);
|
|
295
|
+
const creditScope = { ...scope, scopeKey: "credits" };
|
|
296
|
+
await t.mutation(m("mutations:record"), {
|
|
297
|
+
...observation("credit", -501),
|
|
298
|
+
...creditScope,
|
|
299
|
+
});
|
|
300
|
+
await t.mutation(m("mutations:reserve"), {
|
|
301
|
+
...reserve("credit", "credit"),
|
|
302
|
+
...creditScope,
|
|
303
|
+
});
|
|
304
|
+
await t.mutation(m("mutations:seal"), { groupKey: "credit" });
|
|
305
|
+
await t.mutation(m("mutations:finish"), {
|
|
306
|
+
groupKey: "credit",
|
|
307
|
+
outcome: "collect",
|
|
308
|
+
});
|
|
309
|
+
expect(
|
|
310
|
+
(
|
|
311
|
+
await t.mutation(m("mutations:rollback"), {
|
|
312
|
+
groupKey: "credit",
|
|
313
|
+
returnedPrincipal: 5000,
|
|
314
|
+
revision: 1,
|
|
315
|
+
})
|
|
316
|
+
).amountDue,
|
|
317
|
+
).toBe(-251);
|
|
318
|
+
});
|
|
319
|
+
it("does not let an empty old group clear another group's lock, and reset includes new tables", async () => {
|
|
320
|
+
const t = convexTest(schema, modules);
|
|
321
|
+
await t.mutation(m("mutations:reserve"), {
|
|
322
|
+
...reserve("empty", "empty"),
|
|
323
|
+
maxDebit: 0,
|
|
324
|
+
maxCredit: 0,
|
|
325
|
+
});
|
|
326
|
+
await t.mutation(m("mutations:record"), observation("a", 100));
|
|
327
|
+
await t.mutation(m("mutations:reserve"), reserve("active", "active"));
|
|
328
|
+
await t.mutation(m("mutations:seal"), { groupKey: "empty" });
|
|
329
|
+
await t.mutation(m("mutations:finish"), {
|
|
330
|
+
groupKey: "empty",
|
|
331
|
+
outcome: "collect",
|
|
332
|
+
});
|
|
333
|
+
expect((await t.query(q("balance"), scope)).activeGroupKey).toBe("active");
|
|
334
|
+
await t.mutation(
|
|
335
|
+
makeFunctionReference<"mutation">("core/mutations:clearAllTables"),
|
|
336
|
+
{},
|
|
337
|
+
);
|
|
338
|
+
expect(await t.query(q("balance"), scope)).toMatchObject({
|
|
339
|
+
amountDue: 0,
|
|
340
|
+
collectedAmount: 0,
|
|
341
|
+
reservedAmount: 0,
|
|
342
|
+
outstanding: 0,
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { type MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import { observationFields, rollbackArgs } from "./schema.js";
|
|
4
|
+
import {
|
|
5
|
+
adjustTotals,
|
|
6
|
+
getGroup,
|
|
7
|
+
integer,
|
|
8
|
+
sign,
|
|
9
|
+
validateScope,
|
|
10
|
+
} from "./helpers.js";
|
|
11
|
+
import type { Infer } from "convex/values";
|
|
12
|
+
const _observation = v.object(observationFields);
|
|
13
|
+
export async function recordClaim(
|
|
14
|
+
ctx: MutationCtx,
|
|
15
|
+
args: Infer<typeof _observation>,
|
|
16
|
+
) {
|
|
17
|
+
validateScope(args);
|
|
18
|
+
[
|
|
19
|
+
args.amountDue,
|
|
20
|
+
args.sourceGross,
|
|
21
|
+
args.sourceProcessingFee,
|
|
22
|
+
args.revision,
|
|
23
|
+
].forEach(integer);
|
|
24
|
+
if (
|
|
25
|
+
!args.claimKey ||
|
|
26
|
+
!args.sourceChargeId ||
|
|
27
|
+
args.sourceGross <= 0 ||
|
|
28
|
+
args.sourceProcessingFee < 0 ||
|
|
29
|
+
args.revision < 0
|
|
30
|
+
)
|
|
31
|
+
throw new Error("Invalid recovery claim observation.");
|
|
32
|
+
const existing = await ctx.db
|
|
33
|
+
.query("recoveryClaims")
|
|
34
|
+
.withIndex("by_key", (q) => q.eq("claimKey", args.claimKey))
|
|
35
|
+
.unique();
|
|
36
|
+
if (existing) {
|
|
37
|
+
for (const key of [
|
|
38
|
+
"accountId",
|
|
39
|
+
"scopeKey",
|
|
40
|
+
"currency",
|
|
41
|
+
"sourceChargeId",
|
|
42
|
+
"sourceGross",
|
|
43
|
+
"sourceProcessingFee",
|
|
44
|
+
] as const)
|
|
45
|
+
if (existing[key] !== args[key])
|
|
46
|
+
throw new Error("Recovery claim source identity cannot change.");
|
|
47
|
+
if (args.revision < existing.revision) return existing;
|
|
48
|
+
if (args.revision === existing.revision) {
|
|
49
|
+
if (args.amountDue !== existing.amountDue)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Recovery revision cannot be reused with a different amount.",
|
|
52
|
+
);
|
|
53
|
+
return existing;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const delta = integer(args.amountDue - (existing?.amountDue ?? 0));
|
|
57
|
+
const collectedAmount = existing?.collectedAmount ?? 0,
|
|
58
|
+
reservedAmount = existing?.reservedAmount ?? 0;
|
|
59
|
+
const outstanding = integer(
|
|
60
|
+
args.amountDue - collectedAmount - reservedAmount,
|
|
61
|
+
);
|
|
62
|
+
const fields = {
|
|
63
|
+
...args,
|
|
64
|
+
collectedAmount,
|
|
65
|
+
reservedAmount,
|
|
66
|
+
outstanding,
|
|
67
|
+
sign: sign(outstanding),
|
|
68
|
+
};
|
|
69
|
+
let id = existing?._id;
|
|
70
|
+
if (id) await ctx.db.patch("recoveryClaims", id, fields);
|
|
71
|
+
else id = await ctx.db.insert("recoveryClaims", fields);
|
|
72
|
+
await adjustTotals(ctx, args, { amountDue: delta, outstanding: delta });
|
|
73
|
+
return (await ctx.db.get("recoveryClaims", id))!;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const _rollbackArgs = v.object(rollbackArgs);
|
|
77
|
+
export async function rollbackClaim(
|
|
78
|
+
ctx: MutationCtx,
|
|
79
|
+
args: Infer<typeof _rollbackArgs>,
|
|
80
|
+
) {
|
|
81
|
+
integer(args.returnedPrincipal);
|
|
82
|
+
integer(args.revision);
|
|
83
|
+
const group = await getGroup(ctx, args.groupKey);
|
|
84
|
+
if (
|
|
85
|
+
group.status !== "collected" ||
|
|
86
|
+
(group.sourceKind === "settlement"
|
|
87
|
+
? !group.sourceGross
|
|
88
|
+
: !group.invoiceFunding)
|
|
89
|
+
)
|
|
90
|
+
throw new Error(
|
|
91
|
+
"Rollback requires a collected group with original funding economics.",
|
|
92
|
+
);
|
|
93
|
+
const funding =
|
|
94
|
+
group.sourceKind === "settlement"
|
|
95
|
+
? {
|
|
96
|
+
sourceChargeId: group.sourceId,
|
|
97
|
+
sourceGross: group.sourceGross!,
|
|
98
|
+
sourceProcessingFee: group.sourceProcessingFee!,
|
|
99
|
+
}
|
|
100
|
+
: group.invoiceFunding!;
|
|
101
|
+
if (
|
|
102
|
+
args.returnedPrincipal < 0 ||
|
|
103
|
+
args.returnedPrincipal > funding.sourceGross
|
|
104
|
+
)
|
|
105
|
+
throw new Error("Returned principal exceeds the original funding sale.");
|
|
106
|
+
const abs = BigInt(Math.abs(group.amount)),
|
|
107
|
+
gross = BigInt(funding.sourceGross),
|
|
108
|
+
principal = BigInt(args.returnedPrincipal);
|
|
109
|
+
const rounded = Number((abs * principal + gross / 2n) / gross);
|
|
110
|
+
return recordClaim(ctx, {
|
|
111
|
+
accountId: group.accountId,
|
|
112
|
+
scopeKey: group.scopeKey,
|
|
113
|
+
currency: group.currency,
|
|
114
|
+
claimKey: `rollback:${group.groupKey}`,
|
|
115
|
+
...funding,
|
|
116
|
+
amountDue: group.amount < 0 ? -rounded : rounded,
|
|
117
|
+
revision: args.revision,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import { finishArgs } from "./schema.js";
|
|
4
|
+
import {
|
|
5
|
+
adjustTotals,
|
|
6
|
+
balance,
|
|
7
|
+
batchLimit,
|
|
8
|
+
getGroup,
|
|
9
|
+
integer,
|
|
10
|
+
sign,
|
|
11
|
+
} from "./helpers.js";
|
|
12
|
+
|
|
13
|
+
import type { Infer } from "convex/values";
|
|
14
|
+
const _finishArgs = v.object(finishArgs);
|
|
15
|
+
export async function finishGroup(
|
|
16
|
+
ctx: MutationCtx,
|
|
17
|
+
args: Infer<typeof _finishArgs>,
|
|
18
|
+
) {
|
|
19
|
+
const limit = args.limit ?? 10;
|
|
20
|
+
batchLimit(limit, 10);
|
|
21
|
+
const group = await getGroup(ctx, args.groupKey);
|
|
22
|
+
const collect = args.outcome === "collect",
|
|
23
|
+
terminal = collect ? "collected" : "released",
|
|
24
|
+
progress = collect ? "collecting" : "releasing";
|
|
25
|
+
if (group.status === terminal) return { group, isDone: true };
|
|
26
|
+
if (
|
|
27
|
+
(collect && ["released", "releasing"].includes(group.status)) ||
|
|
28
|
+
(!collect && ["collected", "collecting"].includes(group.status))
|
|
29
|
+
)
|
|
30
|
+
throw new Error("Recovery outcome cannot change.");
|
|
31
|
+
if (collect && group.status === "open")
|
|
32
|
+
throw new Error("Seal recovery before confirming provider collection.");
|
|
33
|
+
const operations = await ctx.db
|
|
34
|
+
.query("recoveryReservations")
|
|
35
|
+
.withIndex("by_group_status", (q) =>
|
|
36
|
+
q.eq("groupKey", group.groupKey).eq("status", "reserved"),
|
|
37
|
+
)
|
|
38
|
+
.take(limit);
|
|
39
|
+
let amount = 0;
|
|
40
|
+
for (const operation of operations) {
|
|
41
|
+
for (const allocation of operation.allocations) {
|
|
42
|
+
const claim = await ctx.db.get("recoveryClaims", allocation.claimId);
|
|
43
|
+
if (
|
|
44
|
+
!claim ||
|
|
45
|
+
claim.accountId !== group.accountId ||
|
|
46
|
+
claim.scopeKey !== group.scopeKey ||
|
|
47
|
+
claim.currency !== group.currency
|
|
48
|
+
)
|
|
49
|
+
throw new Error("Recovery allocation identity does not match.");
|
|
50
|
+
const reservedAmount = integer(claim.reservedAmount - allocation.amount);
|
|
51
|
+
const collectedAmount = integer(
|
|
52
|
+
claim.collectedAmount + (collect ? allocation.amount : 0),
|
|
53
|
+
);
|
|
54
|
+
const outstanding = integer(
|
|
55
|
+
claim.amountDue - collectedAmount - reservedAmount,
|
|
56
|
+
);
|
|
57
|
+
await ctx.db.patch("recoveryClaims", claim._id, {
|
|
58
|
+
reservedAmount,
|
|
59
|
+
collectedAmount,
|
|
60
|
+
outstanding,
|
|
61
|
+
sign: sign(outstanding),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
await ctx.db.patch("recoveryReservations", operation._id, {
|
|
65
|
+
status: terminal,
|
|
66
|
+
});
|
|
67
|
+
amount = integer(amount + operation.amount);
|
|
68
|
+
}
|
|
69
|
+
await adjustTotals(ctx, group, {
|
|
70
|
+
reservedAmount: -amount,
|
|
71
|
+
collectedAmount: collect ? amount : 0,
|
|
72
|
+
outstanding: collect ? 0 : amount,
|
|
73
|
+
});
|
|
74
|
+
const pendingOperations = group.pendingOperations - operations.length;
|
|
75
|
+
if (pendingOperations < 0)
|
|
76
|
+
throw new Error("Recovery operation count does not reconcile.");
|
|
77
|
+
await ctx.db.patch("recoveryGroups", group._id, {
|
|
78
|
+
pendingOperations,
|
|
79
|
+
status: pendingOperations ? progress : terminal,
|
|
80
|
+
});
|
|
81
|
+
if (!pendingOperations) {
|
|
82
|
+
const ledger = await balance(ctx, group);
|
|
83
|
+
if (ledger.activeGroupKey === group.groupKey)
|
|
84
|
+
await ctx.db.patch("recoveryBalances", ledger._id, {
|
|
85
|
+
activeGroupKey: undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
group: (await ctx.db.get("recoveryGroups", group._id))!,
|
|
90
|
+
isDone: pendingOperations === 0,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
2
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
3
|
+
export type Scope = { accountId: string; scopeKey: string; currency: string };
|
|
4
|
+
export function integer(value: number) {
|
|
5
|
+
if (!Number.isSafeInteger(value))
|
|
6
|
+
throw new Error("Recovery amounts must be safe integers.");
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function validateScope(scope: Scope) {
|
|
10
|
+
if (!scope.accountId || !scope.scopeKey || !/^[a-z]{3}$/.test(scope.currency))
|
|
11
|
+
throw new Error("Recovery account, scope and currency are required.");
|
|
12
|
+
}
|
|
13
|
+
export const sign = (value: number) =>
|
|
14
|
+
value > 0
|
|
15
|
+
? ("positive" as const)
|
|
16
|
+
: value < 0
|
|
17
|
+
? ("negative" as const)
|
|
18
|
+
: ("zero" as const);
|
|
19
|
+
export const scopeOnly = (scope: Scope): Scope => ({
|
|
20
|
+
accountId: scope.accountId,
|
|
21
|
+
scopeKey: scope.scopeKey,
|
|
22
|
+
currency: scope.currency,
|
|
23
|
+
});
|
|
24
|
+
export const zeroTotals = {
|
|
25
|
+
amountDue: 0,
|
|
26
|
+
collectedAmount: 0,
|
|
27
|
+
reservedAmount: 0,
|
|
28
|
+
outstanding: 0,
|
|
29
|
+
};
|
|
30
|
+
export function readBalance(ctx: QueryCtx | MutationCtx, scope: Scope) {
|
|
31
|
+
return ctx.db
|
|
32
|
+
.query("recoveryBalances")
|
|
33
|
+
.withIndex("by_scope", (q) =>
|
|
34
|
+
q
|
|
35
|
+
.eq("accountId", scope.accountId)
|
|
36
|
+
.eq("scopeKey", scope.scopeKey)
|
|
37
|
+
.eq("currency", scope.currency),
|
|
38
|
+
)
|
|
39
|
+
.unique();
|
|
40
|
+
}
|
|
41
|
+
export async function balance(ctx: MutationCtx, scope: Scope) {
|
|
42
|
+
const found = await readBalance(ctx, scope);
|
|
43
|
+
if (found) return found;
|
|
44
|
+
const id = await ctx.db.insert("recoveryBalances", {
|
|
45
|
+
...scopeOnly(scope),
|
|
46
|
+
...zeroTotals,
|
|
47
|
+
});
|
|
48
|
+
return (await ctx.db.get("recoveryBalances", id))!;
|
|
49
|
+
}
|
|
50
|
+
export async function adjustTotals(
|
|
51
|
+
ctx: MutationCtx,
|
|
52
|
+
scope: Scope,
|
|
53
|
+
delta: Partial<typeof zeroTotals>,
|
|
54
|
+
) {
|
|
55
|
+
const stored = await balance(ctx, scope);
|
|
56
|
+
const next = { ...zeroTotals };
|
|
57
|
+
for (const key of Object.keys(zeroTotals) as (keyof typeof zeroTotals)[])
|
|
58
|
+
next[key] = integer(stored[key] + (delta[key] ?? 0));
|
|
59
|
+
if (
|
|
60
|
+
next.outstanding !==
|
|
61
|
+
integer(next.amountDue - next.collectedAmount - next.reservedAmount)
|
|
62
|
+
)
|
|
63
|
+
throw new Error("Recovery totals do not reconcile.");
|
|
64
|
+
await ctx.db.patch("recoveryBalances", stored._id, next);
|
|
65
|
+
}
|
|
66
|
+
export function groupTarget(group: Doc<"recoveryGroups">, outstanding: number) {
|
|
67
|
+
return Math.max(
|
|
68
|
+
-group.maxCredit,
|
|
69
|
+
Math.min(group.maxDebit, integer(outstanding + group.amount)),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
export async function getGroup(ctx: MutationCtx, key: string) {
|
|
73
|
+
const group = await ctx.db
|
|
74
|
+
.query("recoveryGroups")
|
|
75
|
+
.withIndex("by_key", (q) => q.eq("groupKey", key))
|
|
76
|
+
.unique();
|
|
77
|
+
if (!group) throw new Error("Recovery group is missing.");
|
|
78
|
+
return group;
|
|
79
|
+
}
|
|
80
|
+
export function batchLimit(limit: number, max = 100) {
|
|
81
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > max)
|
|
82
|
+
throw new Error(`Recovery batch limit must be between 1 and ${max}.`);
|
|
83
|
+
}
|