@fantastic.dev/better-stripe 0.22.0 → 0.23.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 (91) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -3
  3. package/dist/client/connect/payoutActivationProof.d.ts +16 -0
  4. package/dist/client/connect/payoutActivationProof.d.ts.map +1 -0
  5. package/dist/client/connect/payoutActivationProof.js +72 -0
  6. package/dist/client/connect/payoutActivationProof.js.map +1 -0
  7. package/dist/client/connect/payoutAdoption.d.ts +35 -0
  8. package/dist/client/connect/payoutAdoption.d.ts.map +1 -0
  9. package/dist/client/connect/payoutAdoption.js +230 -0
  10. package/dist/client/connect/payoutAdoption.js.map +1 -0
  11. package/dist/client/connect/payoutAdoptionAge.d.ts +19 -0
  12. package/dist/client/connect/payoutAdoptionAge.d.ts.map +1 -0
  13. package/dist/client/connect/payoutAdoptionAge.js +56 -0
  14. package/dist/client/connect/payoutAdoptionAge.js.map +1 -0
  15. package/dist/client/connect/payoutAdoptionAllocation.d.ts +24 -0
  16. package/dist/client/connect/payoutAdoptionAllocation.d.ts.map +1 -0
  17. package/dist/client/connect/payoutAdoptionAllocation.js +78 -0
  18. package/dist/client/connect/payoutAdoptionAllocation.js.map +1 -0
  19. package/dist/client/connect/payoutReconciliation.d.ts.map +1 -1
  20. package/dist/client/connect/payoutReconciliation.js +4 -2
  21. package/dist/client/connect/payoutReconciliation.js.map +1 -1
  22. package/dist/client/payoutPolicy.d.ts +7 -1
  23. package/dist/client/payoutPolicy.d.ts.map +1 -1
  24. package/dist/client/payoutPolicy.js +78 -7
  25. package/dist/client/payoutPolicy.js.map +1 -1
  26. package/dist/component/_generated/component.d.ts +196 -0
  27. package/dist/component/_generated/component.d.ts.map +1 -1
  28. package/dist/component/earnings/triggers.d.ts +38 -4
  29. package/dist/component/earnings/triggers.d.ts.map +1 -1
  30. package/dist/component/payoutPolicy/adoption.d.ts +9 -0
  31. package/dist/component/payoutPolicy/adoption.d.ts.map +1 -0
  32. package/dist/component/payoutPolicy/adoption.js +45 -0
  33. package/dist/component/payoutPolicy/adoption.js.map +1 -0
  34. package/dist/component/payoutPolicy/mutations.d.ts +96 -0
  35. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  36. package/dist/component/payoutPolicy/mutations.js +38 -5
  37. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  38. package/dist/component/payoutPolicy/queries.d.ts +16 -0
  39. package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
  40. package/dist/component/payoutPolicy/schema.d.ts +299 -2
  41. package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
  42. package/dist/component/payoutPolicy/schema.js +7 -0
  43. package/dist/component/payoutPolicy/schema.js.map +1 -1
  44. package/dist/component/payoutRelease/adoption.d.ts +269 -0
  45. package/dist/component/payoutRelease/adoption.d.ts.map +1 -0
  46. package/dist/component/payoutRelease/adoption.js +197 -0
  47. package/dist/component/payoutRelease/adoption.js.map +1 -0
  48. package/dist/component/payoutRelease/helpers.d.ts +3 -0
  49. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  50. package/dist/component/payoutRelease/helpers.js +20 -0
  51. package/dist/component/payoutRelease/helpers.js.map +1 -1
  52. package/dist/component/payoutRelease/mutations.d.ts +81 -0
  53. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  54. package/dist/component/payoutRelease/mutations.js +6 -0
  55. package/dist/component/payoutRelease/mutations.js.map +1 -1
  56. package/dist/component/payoutRelease/observation.d.ts +18 -0
  57. package/dist/component/payoutRelease/observation.d.ts.map +1 -0
  58. package/dist/component/payoutRelease/observation.js +11 -0
  59. package/dist/component/payoutRelease/observation.js.map +1 -0
  60. package/dist/component/payoutRelease/queries.d.ts +3 -0
  61. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  62. package/dist/component/payoutRelease/schema.d.ts +8 -19
  63. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  64. package/dist/component/payoutRelease/schema.js +3 -9
  65. package/dist/component/payoutRelease/schema.js.map +1 -1
  66. package/dist/component/schema.d.ts +73 -2
  67. package/docs/settlement.md +121 -40
  68. package/package.json +1 -1
  69. package/src/client/connect/payoutActivationProof.ts +101 -0
  70. package/src/client/connect/payoutAdoption.test.ts +421 -0
  71. package/src/client/connect/payoutAdoption.ts +330 -0
  72. package/src/client/connect/payoutAdoptionAge.test.ts +89 -0
  73. package/src/client/connect/payoutAdoptionAge.ts +74 -0
  74. package/src/client/connect/payoutAdoptionAllocation.test.ts +132 -0
  75. package/src/client/connect/payoutAdoptionAllocation.ts +106 -0
  76. package/src/client/connect/payoutReconciliation.test.ts +16 -0
  77. package/src/client/connect/payoutReconciliation.ts +6 -2
  78. package/src/client/payoutPolicy.test.ts +16 -0
  79. package/src/client/payoutPolicy.ts +107 -5
  80. package/src/component/_generated/api.ts +6 -0
  81. package/src/component/_generated/component.ts +192 -1
  82. package/src/component/payout-adoption.test.ts +272 -0
  83. package/src/component/payout-policy-adoption.test.ts +131 -0
  84. package/src/component/payoutPolicy/adoption.ts +76 -0
  85. package/src/component/payoutPolicy/mutations.ts +46 -3
  86. package/src/component/payoutPolicy/schema.ts +9 -0
  87. package/src/component/payoutRelease/adoption.ts +262 -0
  88. package/src/component/payoutRelease/helpers.ts +21 -0
  89. package/src/component/payoutRelease/mutations.ts +10 -0
  90. package/src/component/payoutRelease/observation.ts +10 -0
  91. package/src/component/payoutRelease/schema.ts +3 -9
@@ -2199,11 +2199,41 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2199
2199
  beginAttempt: FunctionReference<
2200
2200
  "mutation",
2201
2201
  "internal",
2202
- { accountId: string; operationKey: string; ownerKey: string },
2202
+ {
2203
+ accountId: string;
2204
+ adoption?: {
2205
+ claims: Array<{ fingerprint: string; operationKey: string }>;
2206
+ observations: Array<{
2207
+ currency: string;
2208
+ ledgerFingerprint: string;
2209
+ matureNet: number;
2210
+ observedAt: number;
2211
+ pendingCredits: number;
2212
+ stripeTransferId: string;
2213
+ transactionIds: Array<string>;
2214
+ }>;
2215
+ oldestHeldAt?: number;
2216
+ };
2217
+ operationKey: string;
2218
+ ownerKey: string;
2219
+ },
2203
2220
  {
2204
2221
  _creationTime: number;
2205
2222
  _id: string;
2206
2223
  accountId: string;
2224
+ adoption?: {
2225
+ claims: Array<{ fingerprint: string; operationKey: string }>;
2226
+ observations: Array<{
2227
+ currency: string;
2228
+ ledgerFingerprint: string;
2229
+ matureNet: number;
2230
+ observedAt: number;
2231
+ pendingCredits: number;
2232
+ stripeTransferId: string;
2233
+ transactionIds: Array<string>;
2234
+ }>;
2235
+ oldestHeldAt?: number;
2236
+ };
2207
2237
  attempted?: boolean;
2208
2238
  country: string;
2209
2239
  expectedRevision: number;
@@ -2249,6 +2279,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2249
2279
  _creationTime: number;
2250
2280
  _id: string;
2251
2281
  accountId: string;
2282
+ adoption?: {
2283
+ claims: Array<{ fingerprint: string; operationKey: string }>;
2284
+ observations: Array<{
2285
+ currency: string;
2286
+ ledgerFingerprint: string;
2287
+ matureNet: number;
2288
+ observedAt: number;
2289
+ pendingCredits: number;
2290
+ stripeTransferId: string;
2291
+ transactionIds: Array<string>;
2292
+ }>;
2293
+ oldestHeldAt?: number;
2294
+ };
2252
2295
  attempted?: boolean;
2253
2296
  country: string;
2254
2297
  expectedRevision: number;
@@ -2294,6 +2337,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2294
2337
  _creationTime: number;
2295
2338
  _id: string;
2296
2339
  accountId: string;
2340
+ adoption?: {
2341
+ claims: Array<{ fingerprint: string; operationKey: string }>;
2342
+ observations: Array<{
2343
+ currency: string;
2344
+ ledgerFingerprint: string;
2345
+ matureNet: number;
2346
+ observedAt: number;
2347
+ pendingCredits: number;
2348
+ stripeTransferId: string;
2349
+ transactionIds: Array<string>;
2350
+ }>;
2351
+ oldestHeldAt?: number;
2352
+ };
2297
2353
  attempted?: boolean;
2298
2354
  country: string;
2299
2355
  expectedRevision: number;
@@ -2327,6 +2383,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2327
2383
  "internal",
2328
2384
  {
2329
2385
  accountId: string;
2386
+ adoption?: {
2387
+ claims: Array<{ fingerprint: string; operationKey: string }>;
2388
+ observations: Array<{
2389
+ currency: string;
2390
+ ledgerFingerprint: string;
2391
+ matureNet: number;
2392
+ observedAt: number;
2393
+ pendingCredits: number;
2394
+ stripeTransferId: string;
2395
+ transactionIds: Array<string>;
2396
+ }>;
2397
+ oldestHeldAt?: number;
2398
+ };
2330
2399
  country: string;
2331
2400
  expectedRevision: number;
2332
2401
  mode: "stripe_managed" | "custom";
@@ -2344,6 +2413,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2344
2413
  _creationTime: number;
2345
2414
  _id: string;
2346
2415
  accountId: string;
2416
+ adoption?: {
2417
+ claims: Array<{ fingerprint: string; operationKey: string }>;
2418
+ observations: Array<{
2419
+ currency: string;
2420
+ ledgerFingerprint: string;
2421
+ matureNet: number;
2422
+ observedAt: number;
2423
+ pendingCredits: number;
2424
+ stripeTransferId: string;
2425
+ transactionIds: Array<string>;
2426
+ }>;
2427
+ oldestHeldAt?: number;
2428
+ };
2347
2429
  attempted?: boolean;
2348
2430
  country: string;
2349
2431
  expectedRevision: number;
@@ -2444,6 +2526,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2444
2526
  _creationTime: number;
2445
2527
  _id: string;
2446
2528
  accountId: string;
2529
+ adoption?: {
2530
+ claims: Array<{ fingerprint: string; operationKey: string }>;
2531
+ observations: Array<{
2532
+ currency: string;
2533
+ ledgerFingerprint: string;
2534
+ matureNet: number;
2535
+ observedAt: number;
2536
+ pendingCredits: number;
2537
+ stripeTransferId: string;
2538
+ transactionIds: Array<string>;
2539
+ }>;
2540
+ oldestHeldAt?: number;
2541
+ };
2447
2542
  attempted?: boolean;
2448
2543
  country: string;
2449
2544
  expectedRevision: number;
@@ -2476,6 +2571,94 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2476
2571
  };
2477
2572
  payoutRelease: {
2478
2573
  mutations: {
2574
+ adoptHistoricalBatch: FunctionReference<
2575
+ "mutation",
2576
+ "internal",
2577
+ {
2578
+ accountId: string;
2579
+ expectedClaims: Array<{
2580
+ fingerprint: string;
2581
+ operationKey: string;
2582
+ }>;
2583
+ expectedRevision: number;
2584
+ observations: Array<{
2585
+ currency: string;
2586
+ ledgerFingerprint: string;
2587
+ matureNet: number;
2588
+ observedAt: number;
2589
+ pendingCredits: number;
2590
+ stripeTransferId: string;
2591
+ transactionIds: Array<string>;
2592
+ }>;
2593
+ ownerKey: string;
2594
+ payouts: Array<{
2595
+ allocations: Array<{
2596
+ amount: number;
2597
+ role?: "store" | "affiliate" | "other";
2598
+ stripeTransferId: string;
2599
+ }>;
2600
+ payout: {
2601
+ amount: number;
2602
+ created: number;
2603
+ currency: string;
2604
+ debitFee: number;
2605
+ debitTransactionId: string;
2606
+ destinationId: string;
2607
+ returnFee?: number;
2608
+ returnTransactionId?: string;
2609
+ status: "paid" | "returned";
2610
+ stripePayoutId: string;
2611
+ };
2612
+ }>;
2613
+ },
2614
+ {
2615
+ claims: Array<{
2616
+ _creationTime: number;
2617
+ _id: string;
2618
+ accountId: string;
2619
+ allocations: Array<{
2620
+ amount: number;
2621
+ role?: "store" | "affiliate" | "other";
2622
+ stripeTransferId: string;
2623
+ }>;
2624
+ amount: number;
2625
+ attemptedAt?: number;
2626
+ currency: string;
2627
+ debitFee?: number;
2628
+ debitTransactionId?: string;
2629
+ destinationId?: string;
2630
+ expectedRevision: number;
2631
+ feeRevision?: number;
2632
+ historical?: boolean;
2633
+ observations: Array<{
2634
+ currency: string;
2635
+ ledgerFingerprint: string;
2636
+ matureNet: number;
2637
+ observedAt: number;
2638
+ pendingCredits: number;
2639
+ stripeTransferId: string;
2640
+ transactionIds: Array<string>;
2641
+ }>;
2642
+ operationKey: string;
2643
+ ownerKey: string;
2644
+ providerFee?: number;
2645
+ rejection?: {
2646
+ code: "balance_insufficient" | "amount_too_small";
2647
+ requestId: string;
2648
+ };
2649
+ returnTransactionId?: string;
2650
+ status:
2651
+ | "reserved"
2652
+ | "submitted"
2653
+ | "paid"
2654
+ | "returned"
2655
+ | "canceled"
2656
+ | "rejected";
2657
+ stripePayoutId?: string;
2658
+ }>;
2659
+ },
2660
+ Name
2661
+ >;
2479
2662
  beginAttempt: FunctionReference<
2480
2663
  "mutation",
2481
2664
  "internal",
@@ -2497,6 +2680,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2497
2680
  destinationId?: string;
2498
2681
  expectedRevision: number;
2499
2682
  feeRevision?: number;
2683
+ historical?: boolean;
2500
2684
  observations: Array<{
2501
2685
  currency: string;
2502
2686
  ledgerFingerprint: string;
@@ -2546,6 +2730,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2546
2730
  destinationId?: string;
2547
2731
  expectedRevision: number;
2548
2732
  feeRevision?: number;
2733
+ historical?: boolean;
2549
2734
  observations: Array<{
2550
2735
  currency: string;
2551
2736
  ledgerFingerprint: string;
@@ -2613,6 +2798,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2613
2798
  destinationId?: string;
2614
2799
  expectedRevision: number;
2615
2800
  feeRevision?: number;
2801
+ historical?: boolean;
2616
2802
  observations: Array<{
2617
2803
  currency: string;
2618
2804
  ledgerFingerprint: string;
@@ -2685,6 +2871,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2685
2871
  destinationId?: string;
2686
2872
  expectedRevision: number;
2687
2873
  feeRevision?: number;
2874
+ historical?: boolean;
2688
2875
  observations: Array<{
2689
2876
  currency: string;
2690
2877
  ledgerFingerprint: string;
@@ -2740,6 +2927,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2740
2927
  destinationId?: string;
2741
2928
  expectedRevision: number;
2742
2929
  feeRevision?: number;
2930
+ historical?: boolean;
2743
2931
  observations: Array<{
2744
2932
  currency: string;
2745
2933
  ledgerFingerprint: string;
@@ -2798,6 +2986,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2798
2986
  destinationId?: string;
2799
2987
  expectedRevision: number;
2800
2988
  feeRevision?: number;
2989
+ historical?: boolean;
2801
2990
  observations: Array<{
2802
2991
  currency: string;
2803
2992
  ledgerFingerprint: string;
@@ -2861,6 +3050,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2861
3050
  destinationId?: string;
2862
3051
  expectedRevision: number;
2863
3052
  feeRevision?: number;
3053
+ historical?: boolean;
2864
3054
  observations: Array<{
2865
3055
  currency: string;
2866
3056
  ledgerFingerprint: string;
@@ -2913,6 +3103,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2913
3103
  destinationId?: string;
2914
3104
  expectedRevision: number;
2915
3105
  feeRevision?: number;
3106
+ historical?: boolean;
2916
3107
  observations: Array<{
2917
3108
  currency: string;
2918
3109
  ledgerFingerprint: string;
@@ -0,0 +1,272 @@
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
+ import {
7
+ transferFingerprint,
8
+ payoutClaimFingerprint,
9
+ } from "./payoutRelease/helpers.js";
10
+ const modules = import.meta.glob("./**/*.*s");
11
+ const scope = { accountId: "acct_one", ownerKey: "owner" };
12
+
13
+ async function setup() {
14
+ const t = convexTest(schema, modules);
15
+ await t.run(async (ctx) => {
16
+ await ctx.db.insert("payoutPolicies", {
17
+ ...scope,
18
+ mode: "stripe_managed",
19
+ status: "ready",
20
+ revision: 0,
21
+ operationKey: "default",
22
+ country: "US",
23
+ thresholds: [],
24
+ });
25
+ for (const [id, amount, role] of [
26
+ ["tr_one", 600, "store"],
27
+ ["tr_two", 400, "affiliate"],
28
+ ] as const)
29
+ await ctx.db.insert("transfers", {
30
+ stripeTransferId: id,
31
+ amount,
32
+ currency: "usd",
33
+ destinationAccountId: scope.accountId,
34
+ status: "paid",
35
+ role,
36
+ });
37
+ });
38
+ const rows = await t.run((ctx) => ctx.db.query("transfers").collect());
39
+ const args = {
40
+ ...scope,
41
+ expectedRevision: 0,
42
+ payout: {
43
+ stripePayoutId: "po_old",
44
+ amount: 1000,
45
+ currency: "usd",
46
+ created: Math.floor(Date.now() / 1000) - 100,
47
+ destinationId: "ba_one",
48
+ status: "paid",
49
+ debitTransactionId: "txn_debit",
50
+ debitFee: 30,
51
+ },
52
+ allocations: [
53
+ { stripeTransferId: "tr_one", amount: 600 },
54
+ { stripeTransferId: "tr_two", amount: 400 },
55
+ ],
56
+ observations: rows.map((row) => ({
57
+ stripeTransferId: row.stripeTransferId,
58
+ currency: "usd",
59
+ matureNet: row.amount,
60
+ pendingCredits: 0,
61
+ ledgerFingerprint: transferFingerprint(row),
62
+ observedAt: Date.now(),
63
+ transactionIds: [`txn_${row.stripeTransferId}`],
64
+ })),
65
+ };
66
+ return { t, args };
67
+ }
68
+ async function importOne(
69
+ t: ReturnType<typeof convexTest>,
70
+ args: Awaited<ReturnType<typeof setup>>["args"] & {
71
+ payout: { returnTransactionId?: string; returnFee?: number };
72
+ },
73
+ ) {
74
+ const rows = await t.run((ctx) =>
75
+ ctx.db.query("payoutReleaseClaims").collect(),
76
+ );
77
+ const result = await t.mutation(
78
+ makeFunctionReference<"mutation">(
79
+ "payoutRelease/mutations:adoptHistoricalBatch",
80
+ ),
81
+ {
82
+ accountId: args.accountId,
83
+ ownerKey: args.ownerKey,
84
+ expectedRevision: args.expectedRevision,
85
+ observations: args.observations,
86
+ expectedClaims: rows.map((c) => ({
87
+ operationKey: c.operationKey,
88
+ fingerprint: payoutClaimFingerprint(c),
89
+ })),
90
+ payouts: [{ payout: args.payout, allocations: args.allocations }],
91
+ },
92
+ );
93
+ return result.claims[0];
94
+ }
95
+
96
+ it("imports historical paid principal and actual fees once", async () => {
97
+ const { t, args } = await setup();
98
+ expect(await importOne(t, args)).toMatchObject({
99
+ historical: true,
100
+ status: "paid",
101
+ amount: 1000,
102
+ providerFee: 30,
103
+ destinationId: "ba_one",
104
+ });
105
+ await importOne(t, args);
106
+ expect(
107
+ await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
108
+ ).toMatchObject([
109
+ { paidAmount: 600, reservedAmount: 0 },
110
+ { paidAmount: 400, reservedAmount: 0 },
111
+ ]);
112
+ expect(
113
+ await t.run((ctx) => ctx.db.query("payoutFeeBalances").collect()),
114
+ ).toMatchObject([{ amount: 30 }]);
115
+ });
116
+ it("imports a verified returned payout without recording its principal as paid", async () => {
117
+ const { t, args } = await setup();
118
+ expect(
119
+ await importOne(t, {
120
+ ...args,
121
+ payout: {
122
+ ...args.payout,
123
+ status: "returned",
124
+ returnTransactionId: "txn_return",
125
+ returnFee: -10,
126
+ },
127
+ }),
128
+ ).toMatchObject({ status: "returned", providerFee: 20 });
129
+ expect(
130
+ await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
131
+ ).toMatchObject([{ paidAmount: 0 }, { paidAmount: 0 }]);
132
+ });
133
+ it("rejects changing source allocations on replay", async () => {
134
+ const { t, args } = await setup();
135
+ await importOne(t, args);
136
+ await expect(
137
+ importOne(t, {
138
+ ...args,
139
+ allocations: [
140
+ { stripeTransferId: "tr_one", amount: 500 },
141
+ { stripeTransferId: "tr_two", amount: 500 },
142
+ ],
143
+ }),
144
+ ).rejects.toThrow(/identity|allocation/i);
145
+ });
146
+ it.each([999, 1001, 0, -1, 1.5])(
147
+ "requires exact positive integer allocations: %s",
148
+ async (amount) => {
149
+ const { t, args } = await setup();
150
+ await expect(
151
+ importOne(t, {
152
+ ...args,
153
+ allocations: [{ stripeTransferId: "tr_one", amount }],
154
+ }),
155
+ ).rejects.toThrow();
156
+ expect(
157
+ await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
158
+ ).toEqual([]);
159
+ },
160
+ );
161
+ it("rejects another owner before any ledger writes", async () => {
162
+ const { t, args } = await setup();
163
+ await expect(importOne(t, { ...args, ownerKey: "other" })).rejects.toThrow(
164
+ /owner/i,
165
+ );
166
+ });
167
+ it("rejects an incomplete source snapshot", async () => {
168
+ const { t, args } = await setup();
169
+ await expect(
170
+ importOne(t, { ...args, observations: args.observations.slice(0, 1) }),
171
+ ).rejects.toThrow(/snapshot/i);
172
+ });
173
+ it("requires inactive automatic policy at the observed revision", async () => {
174
+ const { t, args } = await setup();
175
+ await expect(importOne(t, { ...args, expectedRevision: 1 })).rejects.toThrow(
176
+ /revision/i,
177
+ );
178
+ await t.run(async (ctx) => {
179
+ const p = await ctx.db.query("payoutPolicies").first();
180
+ await ctx.db.patch("payoutPolicies", p!._id, { mode: "custom" });
181
+ });
182
+ await expect(importOne(t, args)).rejects.toThrow(/policy/i);
183
+ });
184
+ it("retains historical paid allocations even when a later refund leaves debt", async () => {
185
+ const { t, args } = await setup();
186
+ await t.run(async (ctx) => {
187
+ const row = await ctx.db.query("transfers").first();
188
+ await ctx.db.patch("transfers", row!._id, { reversedAmount: 500 });
189
+ });
190
+ const rows = await t.run((ctx) => ctx.db.query("transfers").collect());
191
+ const observations = args.observations.map((o) => ({
192
+ ...o,
193
+ matureNet: o.stripeTransferId === "tr_one" ? 100 : o.matureNet,
194
+ ledgerFingerprint: transferFingerprint(
195
+ rows.find((r) => r.stripeTransferId === o.stripeTransferId)!,
196
+ ),
197
+ }));
198
+ await importOne(t, { ...args, observations });
199
+ expect(
200
+ await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
201
+ ).toMatchObject([{ paidAmount: 600 }, { paidAmount: 400 }]);
202
+ });
203
+ it("imports a complete historical batch atomically and rejects stale competing mappings", async () => {
204
+ const { t, args } = await setup();
205
+ const batch = makeFunctionReference<"mutation">(
206
+ "payoutRelease/mutations:adoptHistoricalBatch",
207
+ );
208
+ const input = {
209
+ ...scope,
210
+ expectedRevision: 0,
211
+ observations: args.observations,
212
+ expectedClaims: [],
213
+ payouts: [
214
+ {
215
+ payout: {
216
+ ...args.payout,
217
+ stripePayoutId: "po_first",
218
+ amount: 600,
219
+ debitTransactionId: "txn_first",
220
+ },
221
+ allocations: [{ stripeTransferId: "tr_one", amount: 600 }],
222
+ },
223
+ {
224
+ payout: {
225
+ ...args.payout,
226
+ stripePayoutId: "po_second",
227
+ amount: 400,
228
+ debitTransactionId: "txn_second",
229
+ },
230
+ allocations: [{ stripeTransferId: "tr_two", amount: 400 }],
231
+ },
232
+ ],
233
+ };
234
+ expect(await t.mutation(batch, input)).toMatchObject({
235
+ claims: [{ amount: 600 }, { amount: 400 }],
236
+ });
237
+ await expect(t.mutation(batch, input)).rejects.toThrow(/snapshot|history/i);
238
+ expect(
239
+ await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
240
+ ).toMatchObject([{ paidAmount: 600 }, { paidAmount: 400 }]);
241
+ });
242
+ it("a bad later payout rolls back the entire historical batch", async () => {
243
+ const { t, args } = await setup();
244
+ const batch = makeFunctionReference<"mutation">(
245
+ "payoutRelease/mutations:adoptHistoricalBatch",
246
+ );
247
+ await expect(
248
+ t.mutation(batch, {
249
+ ...scope,
250
+ expectedRevision: 0,
251
+ observations: args.observations,
252
+ expectedClaims: [],
253
+ payouts: [
254
+ { payout: args.payout, allocations: args.allocations },
255
+ {
256
+ payout: {
257
+ ...args.payout,
258
+ stripePayoutId: "po_bad",
259
+ debitTransactionId: "txn_bad",
260
+ },
261
+ allocations: [],
262
+ },
263
+ ],
264
+ }),
265
+ ).rejects.toThrow();
266
+ expect(
267
+ await t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
268
+ ).toEqual([]);
269
+ expect(
270
+ await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
271
+ ).toEqual([]);
272
+ });
@@ -0,0 +1,131 @@
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
+ import { transferFingerprint } from "./payoutRelease/helpers.js";
7
+ const modules = import.meta.glob("./**/*.*s");
8
+ const m = (name: string) =>
9
+ makeFunctionReference<"mutation">(`payoutPolicy/mutations:${name}`);
10
+ const scope = { accountId: "acct_one", ownerKey: "owner" };
11
+ async function setup(country = "US") {
12
+ const t = convexTest(schema, modules);
13
+ await t.mutation(m("prepare"), {
14
+ ...scope,
15
+ operationKey: "default",
16
+ expectedRevision: -1,
17
+ mode: "stripe_managed",
18
+ thresholds: [],
19
+ country,
20
+ observedSchedule: { interval: "weekly", weeklyDays: ["monday"] },
21
+ });
22
+ await t.mutation(m("confirm"), {
23
+ ...scope,
24
+ operationKey: "default",
25
+ observedSchedule: { interval: "weekly", weeklyDays: ["monday"] },
26
+ });
27
+ const row = await t.run(async (ctx) => {
28
+ const id = await ctx.db.insert("transfers", {
29
+ stripeTransferId: "tr_one",
30
+ destinationAccountId: scope.accountId,
31
+ amount: 1000,
32
+ currency: "usd",
33
+ status: "paid",
34
+ role: "store",
35
+ });
36
+ return (await ctx.db.get("transfers", id))!;
37
+ });
38
+ const proof = {
39
+ observations: [
40
+ {
41
+ stripeTransferId: row.stripeTransferId,
42
+ currency: "usd",
43
+ matureNet: 1000,
44
+ pendingCredits: 0,
45
+ ledgerFingerprint: transferFingerprint(row),
46
+ observedAt: Date.now(),
47
+ transactionIds: ["txn_one"],
48
+ },
49
+ ],
50
+ claims: [],
51
+ oldestHeldAt: Date.now() - 20 * 86400000,
52
+ };
53
+ const args = {
54
+ ...scope,
55
+ operationKey: "custom",
56
+ expectedRevision: 0,
57
+ mode: "custom",
58
+ thresholds: [{ currency: "usd", amount: 1000 }],
59
+ country,
60
+ observedSchedule: { interval: "weekly", weeklyDays: ["monday"] },
61
+ adoption: proof,
62
+ };
63
+ return { t, args, proof, row };
64
+ }
65
+ it("anchors funded activation to the oldest held money rather than activation time", async () => {
66
+ const { t, args, proof } = await setup();
67
+ await t.mutation(m("prepare"), args);
68
+ const p = await t.run((ctx) => ctx.db.query("payoutPolicies").first());
69
+ expect(p!.holdDeadline).toBe(proof.oldestHeldAt + 730 * 86400000);
70
+ });
71
+ it("rejects already-expired holding periods before changing policy", async () => {
72
+ const { t, args } = await setup("TH");
73
+ await expect(t.mutation(m("prepare"), args)).rejects.toThrow(
74
+ /holding|deadline/i,
75
+ );
76
+ expect(
77
+ await t.run((ctx) => ctx.db.query("payoutPolicies").first()),
78
+ ).toMatchObject({ mode: "stripe_managed", status: "ready", revision: 0 });
79
+ });
80
+ it("requires fresh adoption proof before a delayed provider attempt", async () => {
81
+ const { t, args } = await setup();
82
+ await t.mutation(m("prepare"), args);
83
+ await expect(
84
+ t.mutation(m("beginAttempt"), { ...scope, operationKey: "custom" }),
85
+ ).rejects.toThrow(/adoption/i);
86
+ });
87
+ it("rejects changed source state before marking the provider attempt", async () => {
88
+ const { t, args, proof, row } = await setup();
89
+ await t.mutation(m("prepare"), args);
90
+ await t.run((ctx) =>
91
+ ctx.db.patch("transfers", row._id, { reversedAmount: 10 }),
92
+ );
93
+ await expect(
94
+ t.mutation(m("beginAttempt"), {
95
+ ...scope,
96
+ operationKey: "custom",
97
+ adoption: proof,
98
+ }),
99
+ ).rejects.toThrow(/snapshot/i);
100
+ expect(
101
+ await t.run((ctx) =>
102
+ ctx.db
103
+ .query("payoutPolicyOperations")
104
+ .withIndex("by_operation", (q) => q.eq("operationKey", "custom"))
105
+ .unique(),
106
+ ),
107
+ ).not.toHaveProperty("attempted");
108
+ });
109
+ it("fresh checks cannot extend the stored holding deadline", async () => {
110
+ const { t, args, proof } = await setup();
111
+ await t.mutation(m("prepare"), args);
112
+ await t.mutation(m("beginAttempt"), {
113
+ ...scope,
114
+ operationKey: "custom",
115
+ adoption: { ...proof, oldestHeldAt: Date.now() - 86400000 },
116
+ });
117
+ expect(
118
+ await t.run((ctx) => ctx.db.query("payoutPolicies").first()),
119
+ ).toMatchObject({ holdDeadline: proof.oldestHeldAt + 730 * 86400000 });
120
+ });
121
+ it("cannot confirm funded adoption without the fresh checked attempt marker", async () => {
122
+ const { t, args } = await setup();
123
+ await t.mutation(m("prepare"), args);
124
+ await expect(
125
+ t.mutation(m("confirm"), {
126
+ ...scope,
127
+ operationKey: "custom",
128
+ observedSchedule: { interval: "manual" },
129
+ }),
130
+ ).rejects.toThrow(/attempt/i);
131
+ });