@fantastic.dev/better-stripe 0.15.0 → 0.17.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 (127) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/MIGRATION.md +6 -0
  3. package/README.md +15 -1
  4. package/dist/client/connect/payoutSnapshot.d.ts +2 -0
  5. package/dist/client/connect/payoutSnapshot.d.ts.map +1 -1
  6. package/dist/client/connect/payoutSnapshot.js +2 -0
  7. package/dist/client/connect/payoutSnapshot.js.map +1 -1
  8. package/dist/client/connect/refunds.d.ts +12 -0
  9. package/dist/client/connect/refunds.d.ts.map +1 -1
  10. package/dist/client/connect/refunds.js +8 -0
  11. package/dist/client/connect/refunds.js.map +1 -1
  12. package/dist/client/index.d.ts +8 -10
  13. package/dist/client/index.d.ts.map +1 -1
  14. package/dist/client/index.js +6 -3
  15. package/dist/client/index.js.map +1 -1
  16. package/dist/client/readContracts.d.ts +50 -0
  17. package/dist/client/readContracts.d.ts.map +1 -0
  18. package/dist/client/readContracts.js +24 -0
  19. package/dist/client/readContracts.js.map +1 -0
  20. package/dist/client/types/documents.d.ts +2 -0
  21. package/dist/client/types/documents.d.ts.map +1 -1
  22. package/dist/client/webhooks/processors.js +2 -0
  23. package/dist/client/webhooks/processors.js.map +1 -1
  24. package/dist/component/_generated/component.d.ts +213 -0
  25. package/dist/component/_generated/component.d.ts.map +1 -1
  26. package/dist/component/connect/mutations.d.ts +2 -0
  27. package/dist/component/connect/mutations.d.ts.map +1 -1
  28. package/dist/component/connect/mutations.js +8 -8
  29. package/dist/component/connect/mutations.js.map +1 -1
  30. package/dist/component/connect/queries.d.ts +102 -2
  31. package/dist/component/connect/queries.d.ts.map +1 -1
  32. package/dist/component/connect/queries.js +88 -45
  33. package/dist/component/connect/queries.js.map +1 -1
  34. package/dist/component/connect/schema.d.ts +14 -2
  35. package/dist/component/connect/schema.d.ts.map +1 -1
  36. package/dist/component/connect/schema.js +19 -1
  37. package/dist/component/connect/schema.js.map +1 -1
  38. package/dist/component/connect/validators.d.ts +13 -2
  39. package/dist/component/connect/validators.d.ts.map +1 -1
  40. package/dist/component/connect/validators.js +4 -0
  41. package/dist/component/connect/validators.js.map +1 -1
  42. package/dist/component/earnings/legacyReads.d.ts +51 -0
  43. package/dist/component/earnings/legacyReads.d.ts.map +1 -0
  44. package/dist/component/earnings/legacyReads.js +43 -0
  45. package/dist/component/earnings/legacyReads.js.map +1 -0
  46. package/dist/component/earnings/mutations.d.ts +9 -0
  47. package/dist/component/earnings/mutations.d.ts.map +1 -0
  48. package/dist/component/earnings/mutations.js +23 -0
  49. package/dist/component/earnings/mutations.js.map +1 -0
  50. package/dist/component/earnings/reads.d.ts +14 -0
  51. package/dist/component/earnings/reads.d.ts.map +1 -0
  52. package/dist/component/earnings/reads.js +28 -0
  53. package/dist/component/earnings/reads.js.map +1 -0
  54. package/dist/component/earnings/schema.d.ts +20 -0
  55. package/dist/component/earnings/schema.d.ts.map +1 -0
  56. package/dist/component/earnings/schema.js +12 -0
  57. package/dist/component/earnings/schema.js.map +1 -0
  58. package/dist/component/earnings/triggers.d.ts +1294 -0
  59. package/dist/component/earnings/triggers.d.ts.map +1 -0
  60. package/dist/component/earnings/triggers.js +68 -0
  61. package/dist/component/earnings/triggers.js.map +1 -0
  62. package/dist/component/products/queries.d.ts +47 -0
  63. package/dist/component/products/queries.d.ts.map +1 -1
  64. package/dist/component/products/queries.js +59 -0
  65. package/dist/component/products/queries.js.map +1 -1
  66. package/dist/component/refundHistory/queries.d.ts +28 -0
  67. package/dist/component/refundHistory/queries.d.ts.map +1 -0
  68. package/dist/component/refundHistory/queries.js +59 -0
  69. package/dist/component/refundHistory/queries.js.map +1 -0
  70. package/dist/component/schema.d.ts +33 -2
  71. package/dist/component/schema.js +2 -0
  72. package/dist/component/schema.js.map +1 -1
  73. package/dist/react/components/EarningsSummary.d.ts +3 -1
  74. package/dist/react/components/EarningsSummary.d.ts.map +1 -1
  75. package/dist/react/components/EarningsSummary.js +3 -3
  76. package/dist/react/components/EarningsSummary.js.map +1 -1
  77. package/dist/react/hooks/useCurrencyEarnings.d.ts +31 -0
  78. package/dist/react/hooks/useCurrencyEarnings.d.ts.map +1 -0
  79. package/dist/react/hooks/useCurrencyEarnings.js +30 -0
  80. package/dist/react/hooks/useCurrencyEarnings.js.map +1 -0
  81. package/dist/react/index.d.ts +3 -0
  82. package/dist/react/index.d.ts.map +1 -1
  83. package/dist/react/index.js +3 -0
  84. package/dist/react/index.js.map +1 -1
  85. package/dist/react/workflows/Costs.d.ts +1 -0
  86. package/dist/react/workflows/Costs.d.ts.map +1 -1
  87. package/dist/react/workflows/Costs.js +7 -3
  88. package/dist/react/workflows/Costs.js.map +1 -1
  89. package/docs/react-workflows.md +40 -0
  90. package/docs/read-contracts.md +136 -0
  91. package/package.json +3 -2
  92. package/src/client/connect/payoutSnapshot.test.ts +27 -0
  93. package/src/client/connect/payoutSnapshot.ts +4 -0
  94. package/src/client/connect/refunds.ts +28 -0
  95. package/src/client/index.ts +10 -11
  96. package/src/client/readContracts.ts +90 -0
  97. package/src/client/readPagination.test.ts +62 -0
  98. package/src/client/refund-history.test.ts +67 -0
  99. package/src/client/types/documents.ts +2 -0
  100. package/src/client/webhooks/payout-canceled.integration.test.ts +25 -0
  101. package/src/client/webhooks/processors.ts +2 -0
  102. package/src/component/_generated/api.ts +10 -0
  103. package/src/component/_generated/component.ts +257 -0
  104. package/src/component/connect/mutations.ts +8 -8
  105. package/src/component/connect/queries.ts +110 -48
  106. package/src/component/connect/schema.ts +19 -1
  107. package/src/component/connect/validators.ts +4 -0
  108. package/src/component/currency-migration.test.ts +119 -0
  109. package/src/component/currency-totals.test.ts +95 -0
  110. package/src/component/earnings/legacyReads.ts +59 -0
  111. package/src/component/earnings/mutations.ts +21 -0
  112. package/src/component/earnings/reads.ts +41 -0
  113. package/src/component/earnings/schema.ts +11 -0
  114. package/src/component/earnings/triggers.ts +91 -0
  115. package/src/component/products/queries.ts +74 -0
  116. package/src/component/public.test.ts +1 -1
  117. package/src/component/read-pagination.test.ts +193 -0
  118. package/src/component/refund-history.test.ts +198 -0
  119. package/src/component/refundHistory/queries.ts +99 -0
  120. package/src/component/schema.ts +2 -0
  121. package/src/react/components/EarningsSummary.tsx +5 -2
  122. package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
  123. package/src/react/hooks/useCurrencyEarnings.ts +73 -0
  124. package/src/react/index.ts +12 -0
  125. package/src/react/read-contracts.render.test.tsx +20 -0
  126. package/src/react/workflows/Costs.test.tsx +21 -0
  127. package/src/react/workflows/Costs.tsx +9 -3
@@ -1,3 +1,8 @@
1
+ import {
2
+ getAccountEarnings as getAccountEarningsLegacy,
3
+ getAccountPayouts as getAccountPayoutsLegacy,
4
+ } from "../earnings/legacyReads.js";
5
+ import { currencyTotals, previewSize } from "../earnings/reads.js";
1
6
  import { v } from "convex/values";
2
7
  import { paginator } from "convex-helpers/server/pagination";
3
8
  import schema from "../schema.js";
@@ -21,9 +26,7 @@ import {
21
26
  wedgedReversalClaimValidator,
22
27
  } from "./validators";
23
28
 
24
- // =============================================================================
25
29
  // PAYMENT QUERIES
26
- // =============================================================================
27
30
 
28
31
  export const getPaymentByStripeId = query({
29
32
  args: { stripePaymentIntentId: v.string() },
@@ -38,9 +41,7 @@ export const getPaymentByStripeId = query({
38
41
  },
39
42
  });
40
43
 
41
- // =============================================================================
42
44
  // PAYOUT QUERIES
43
- // =============================================================================
44
45
 
45
46
  export const getPayout = query({
46
47
  args: { payoutId: v.id("payouts") },
@@ -111,9 +112,7 @@ export const listPayouts = query({
111
112
  },
112
113
  });
113
114
 
114
- // =============================================================================
115
115
  // REFUND QUERIES
116
- // =============================================================================
117
116
 
118
117
  export const getRefundByStripeId = query({
119
118
  args: { stripeRefundId: v.string() },
@@ -196,9 +195,7 @@ export const listRefunds = query({
196
195
  },
197
196
  });
198
197
 
199
- // =============================================================================
200
198
  // DISPUTE QUERIES
201
- // =============================================================================
202
199
 
203
200
  export const getDisputeByStripeId = query({
204
201
  args: { stripeDisputeId: v.string() },
@@ -279,9 +276,7 @@ export const listDisputes = query({
279
276
  },
280
277
  });
281
278
 
282
- // =============================================================================
283
279
  // TRANSFER QUERIES (ledger — BTS-12)
284
- // =============================================================================
285
280
 
286
281
  export const getTransferByStripeId = query({
287
282
  args: { stripeTransferId: v.string() },
@@ -366,9 +361,7 @@ export const paginateTransfersByAccount = query({
366
361
  .paginate(args.paginationOpts),
367
362
  });
368
363
 
369
- // =============================================================================
370
364
  // REVERSAL-CLAIM RECLAIM (BTS-74) — ops diagnostics for wedged legs
371
- // =============================================================================
372
365
 
373
366
  /**
374
367
  * Fetch one reversal operation's claim record (BTS-63/74) by its id, or null.
@@ -447,9 +440,7 @@ export const listWedgedReversalClaims = query({
447
440
  },
448
441
  });
449
442
 
450
- // =============================================================================
451
443
  // EARNINGS AGGREGATES (BTS-64)
452
- // =============================================================================
453
444
  //
454
445
  // The row-list queries above `.take(50)`, so summing their rows client-side
455
446
  // (as the earnings hooks did) silently understated totals for accounts with
@@ -479,27 +470,7 @@ export const getAccountEarnings = query({
479
470
  transferCount: v.number(),
480
471
  transfers: v.array(transferDocValidator),
481
472
  }),
482
- handler: async (ctx, args) => {
483
- const previewLimit = args.previewLimit ?? 50;
484
- let gross = 0;
485
- let reversed = 0;
486
- let transferCount = 0;
487
- const transfers: Doc<"transfers">[] = [];
488
-
489
- for await (const tr of ctx.db
490
- .query("transfers")
491
- .withIndex("by_destination_account_id", (q) =>
492
- q.eq("destinationAccountId", args.destinationAccountId),
493
- )
494
- .order("desc")) {
495
- gross += tr.amount;
496
- reversed += tr.reversedAmount ?? 0;
497
- transferCount += 1;
498
- if (transfers.length < previewLimit) transfers.push(tr);
499
- }
500
-
501
- return { gross, reversed, transferCount, transfers };
502
- },
473
+ handler: getAccountEarningsLegacy,
503
474
  });
504
475
 
505
476
  /**
@@ -515,20 +486,111 @@ export const getAccountPayouts = query({
515
486
  payoutCount: v.number(),
516
487
  payouts: v.array(payoutDocValidator),
517
488
  }),
518
- handler: async (ctx, args) => {
519
- const previewLimit = args.previewLimit ?? 50;
520
- let paidOut = 0;
521
- let payoutCount = 0;
522
- const payouts: Doc<"payouts">[] = [];
489
+ handler: getAccountPayoutsLegacy,
490
+ });
523
491
 
524
- for await (const p of ctx.db
525
- .query("payouts")
526
- .withIndex("by_account_id", (q) => q.eq("accountId", args.accountId))) {
527
- payoutCount += 1;
528
- if (p.status === "paid") paidOut += p.amount;
529
- if (payouts.length < previewLimit) payouts.push(p);
530
- }
492
+ /** Reactive, account-scoped history. A page is never a financial total. */
493
+ export const paginatePayouts = query({
494
+ args: {
495
+ accountId: v.string(),
496
+ status: v.optional(payoutStatusValidator),
497
+ paginationOpts: paginationOptsValidator,
498
+ },
499
+ returns: paginationResultValidator(payoutDocValidator),
500
+ handler: (ctx, args) => {
501
+ if (
502
+ !Number.isInteger(args.paginationOpts.numItems) ||
503
+ args.paginationOpts.numItems < 1 ||
504
+ args.paginationOpts.numItems > 100
505
+ )
506
+ throw new Error("Page size must be an integer from 1 to 100");
507
+ const rows = paginator(ctx.db, schema).query("payouts");
508
+ const scoped =
509
+ args.status === undefined
510
+ ? rows.withIndex("by_account_id", (q) =>
511
+ q.eq("accountId", args.accountId),
512
+ )
513
+ : rows.withIndex("by_account_status", (q) =>
514
+ q.eq("accountId", args.accountId).eq("status", args.status!),
515
+ );
516
+ return scoped.order("desc").paginate({
517
+ ...args.paginationOpts,
518
+ maximumRowsRead: 200,
519
+ maximumBytesRead: 1_000_000,
520
+ });
521
+ },
522
+ });
531
523
 
532
- return { paidOut, payoutCount, payouts };
524
+ export const getAccountEarningsByCurrency = query({
525
+ args: {
526
+ destinationAccountId: v.string(),
527
+ previewLimit: v.optional(v.number()),
528
+ },
529
+ returns: v.object({
530
+ totals: v.array(
531
+ v.object({
532
+ currency: v.string(),
533
+ gross: v.number(),
534
+ reversed: v.number(),
535
+ net: v.number(),
536
+ transferCount: v.number(),
537
+ }),
538
+ ),
539
+ transfers: v.array(transferDocValidator),
540
+ }),
541
+ handler: async (ctx, args) => {
542
+ const limit = previewSize(args.previewLimit);
543
+ const rows = await currencyTotals(
544
+ ctx,
545
+ args.destinationAccountId,
546
+ "earnings",
547
+ );
548
+ const transfers = await ctx.db
549
+ .query("transfers")
550
+ .withIndex("by_destination_account_id", (q) =>
551
+ q.eq("destinationAccountId", args.destinationAccountId),
552
+ )
553
+ .order("desc")
554
+ .take(limit);
555
+ return {
556
+ totals: rows.map((r) => ({
557
+ currency: r.currency,
558
+ gross: r.gross,
559
+ reversed: r.reversed,
560
+ net: r.gross - r.reversed,
561
+ transferCount: r.count,
562
+ })),
563
+ transfers,
564
+ };
565
+ },
566
+ });
567
+ export const getAccountPayoutsByCurrency = query({
568
+ args: { accountId: v.string(), previewLimit: v.optional(v.number()) },
569
+ returns: v.object({
570
+ totals: v.array(
571
+ v.object({
572
+ currency: v.string(),
573
+ paidOut: v.number(),
574
+ payoutCount: v.number(),
575
+ }),
576
+ ),
577
+ payouts: v.array(payoutDocValidator),
578
+ }),
579
+ handler: async (ctx, args) => {
580
+ const limit = previewSize(args.previewLimit);
581
+ const rows = await currencyTotals(ctx, args.accountId, "payouts");
582
+ const payouts = await ctx.db
583
+ .query("payouts")
584
+ .withIndex("by_account_id", (q) => q.eq("accountId", args.accountId))
585
+ .order("desc")
586
+ .take(limit);
587
+ return {
588
+ totals: rows.map((r) => ({
589
+ currency: r.currency,
590
+ paidOut: r.paidOut,
591
+ payoutCount: r.count,
592
+ })),
593
+ payouts,
594
+ };
533
595
  },
534
596
  });
@@ -1,3 +1,4 @@
1
+ import { v } from "convex/values";
1
2
  import { defineTable } from "convex/server";
2
3
 
3
4
  import {
@@ -22,10 +23,14 @@ export const paymentsTable = defineTable(paymentFields)
22
23
  /**
23
24
  * Payouts to V2 accounts (marketplace).
24
25
  */
25
- export const payoutsTable = defineTable(payoutFields)
26
+ export const payoutsTable = defineTable({
27
+ ...payoutFields,
28
+ totalsRecorded: v.optional(v.literal(true)),
29
+ })
26
30
  .index("by_stripe_payout_id", ["stripePayoutId"])
27
31
  .index("by_account_id", ["accountId"])
28
32
  .index("by_status", ["status"])
33
+ .index("by_account_totals_recorded", ["accountId", "totalsRecorded"])
29
34
  .index("by_account_status", ["accountId", "status"]);
30
35
 
31
36
  /**
@@ -49,6 +54,15 @@ export const refundsTable = defineTable(refundFields)
49
54
  .index("by_account_id", ["accountId"])
50
55
  .index("by_status", ["status"])
51
56
  .index("by_account_id_and_status", ["accountId", "status"])
57
+ .index("by_account_id_and_stripe_payment_intent_id", [
58
+ "accountId",
59
+ "stripePaymentIntentId",
60
+ ])
61
+ .index("by_account_id_and_stripe_payment_intent_id_and_status", [
62
+ "accountId",
63
+ "stripePaymentIntentId",
64
+ "status",
65
+ ])
52
66
  .index("by_payment_intent_id_and_status", [
53
67
  "stripePaymentIntentId",
54
68
  "status",
@@ -63,6 +77,10 @@ export const transfersTable = defineTable({
63
77
  ...transferPersistedFields,
64
78
  })
65
79
  .index("by_stripe_transfer_id", ["stripeTransferId"])
80
+ .index("by_account_totals_recorded", [
81
+ "destinationAccountId",
82
+ "totalsRecorded",
83
+ ])
66
84
  .index("by_destination_account_id", ["destinationAccountId"])
67
85
  .index("by_source_charge_id", ["sourceChargeId"]);
68
86
 
@@ -72,6 +72,8 @@ export const payoutFields = {
72
72
  status: payoutStatusValidator,
73
73
  arrivalDate: v.optional(v.string()),
74
74
  method: v.optional(v.string()),
75
+ failureCode: v.optional(v.string()),
76
+ failureMessage: v.optional(v.string()),
75
77
  metadata: v.optional(v.any()),
76
78
  };
77
79
 
@@ -80,6 +82,7 @@ export const payoutDocValidator = v.object({
80
82
  _id: v.id("payouts"),
81
83
  _creationTime: v.number(),
82
84
  ...payoutFields,
85
+ totalsRecorded: v.optional(v.literal(true)),
83
86
  });
84
87
 
85
88
  // =============================================================================
@@ -266,6 +269,7 @@ export const transferFields = {
266
269
  * redelivered base payload must not clobber an in-flight reversal claim.
267
270
  */
268
271
  export const transferPersistedFields = {
272
+ totalsRecorded: v.optional(v.literal(true)),
269
273
  /**
270
274
  * Reversal claim frontier (BTS-63): the highest cumulative reversal amount
271
275
  * reserved by `claimReversalSlices` before money moves. Always ≥
@@ -0,0 +1,119 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { expect, it } from "vitest";
4
+ import { api } from "./_generated/api.js";
5
+ import schema from "./schema.js";
6
+ const modules = import.meta.glob("./**/*.*s");
7
+ it("backfills legacy rows in bounded pages, safely interleaved with updates and replay", async () => {
8
+ const t = convexTest(schema, modules);
9
+ await t.run(async (ctx) => {
10
+ for (let i = 0; i < 205; i++)
11
+ await ctx.db.insert("transfers", {
12
+ stripeTransferId: `legacy_${i}`,
13
+ destinationAccountId: "acct_old",
14
+ amount: 100,
15
+ currency: "usd",
16
+ status: "paid",
17
+ });
18
+ });
19
+ await expect(
20
+ t.query(api.connect.queries.getAccountEarningsByCurrency, {
21
+ destinationAccountId: "acct_old",
22
+ }),
23
+ ).rejects.toThrow("backfill");
24
+ let cursor: string | null = null;
25
+ for (let i = 0; i < 3; i++) {
26
+ const result: { isDone: boolean; continueCursor: string } =
27
+ await t.mutation(api.earnings.mutations.backfillCurrencyTotals, {
28
+ kind: "earnings",
29
+ cursor,
30
+ });
31
+ cursor = result.continueCursor;
32
+ if (i === 0) {
33
+ await t.mutation(api.connect.mutations.recordTransferReversal, {
34
+ stripeTransferId: "legacy_150",
35
+ reversedAmount: 50,
36
+ });
37
+ await expect(
38
+ t.query(api.connect.queries.getAccountEarningsByCurrency, {
39
+ destinationAccountId: "acct_old",
40
+ }),
41
+ ).rejects.toThrow("backfill");
42
+ }
43
+ expect(result.isDone).toBe(i === 2);
44
+ }
45
+ const read = () =>
46
+ t.query(api.connect.queries.getAccountEarningsByCurrency, {
47
+ destinationAccountId: "acct_old",
48
+ previewLimit: 0,
49
+ });
50
+ expect(await read()).toEqual({
51
+ totals: [
52
+ {
53
+ currency: "usd",
54
+ gross: 20500,
55
+ reversed: 50,
56
+ net: 20450,
57
+ transferCount: 205,
58
+ },
59
+ ],
60
+ transfers: [],
61
+ });
62
+ await t.mutation(api.earnings.mutations.backfillCurrencyTotals, {
63
+ kind: "earnings",
64
+ cursor: null,
65
+ });
66
+ expect((await read()).totals[0].gross).toBe(20500);
67
+ });
68
+ it("moves changed payout contributions between accounts and currencies, counting only paid rows", async () => {
69
+ const t = convexTest(schema, modules);
70
+ const args = {
71
+ stripePayoutId: "po_move",
72
+ accountId: "acct_old",
73
+ amount: 100,
74
+ currency: "usd",
75
+ status: "paid" as const,
76
+ };
77
+ await t.run((ctx) => ctx.db.insert("payouts", args));
78
+ await expect(
79
+ t.query(api.connect.queries.getAccountPayoutsByCurrency, {
80
+ accountId: "acct_old",
81
+ }),
82
+ ).rejects.toThrow("backfill");
83
+ await t.mutation(api.earnings.mutations.backfillCurrencyTotals, {
84
+ kind: "payouts",
85
+ cursor: null,
86
+ });
87
+ await t.mutation(api.connect.mutations.upsertPayout, {
88
+ ...args,
89
+ accountId: "acct_new",
90
+ currency: "jpy",
91
+ status: "failed",
92
+ });
93
+ expect(
94
+ (
95
+ await t.query(api.connect.queries.getAccountPayoutsByCurrency, {
96
+ accountId: "acct_old",
97
+ })
98
+ ).totals,
99
+ ).toEqual([]);
100
+ expect(
101
+ (
102
+ await t.query(api.connect.queries.getAccountPayoutsByCurrency, {
103
+ accountId: "acct_new",
104
+ })
105
+ ).totals,
106
+ ).toEqual([{ currency: "jpy", paidOut: 0, payoutCount: 1 }]);
107
+ await t.mutation(api.connect.mutations.upsertPayout, {
108
+ ...args,
109
+ accountId: "acct_new",
110
+ currency: "jpy",
111
+ });
112
+ expect(
113
+ (
114
+ await t.query(api.connect.queries.getAccountPayoutsByCurrency, {
115
+ accountId: "acct_new",
116
+ })
117
+ ).totals[0].paidOut,
118
+ ).toBe(100);
119
+ });
@@ -0,0 +1,95 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { expect, it } from "vitest";
4
+ import { api } from "./_generated/api.js";
5
+ import schema from "./schema.js";
6
+ const modules = import.meta.glob("./**/*.*s");
7
+ it("keeps exact per-currency totals across previews, reversals, reinstatements and payout failures", async () => {
8
+ const t = convexTest(schema, modules);
9
+ for (let i = 0; i < 120; i++) {
10
+ const currency = ["usd", "eur", "jpy", "kwd"][i % 4];
11
+ await t.mutation(api.connect.mutations.upsertTransfer, {
12
+ stripeTransferId: `tr_${i}`,
13
+ destinationAccountId: "acct_a",
14
+ amount: 100,
15
+ currency,
16
+ status: "paid",
17
+ });
18
+ await t.mutation(api.connect.mutations.upsertPayout, {
19
+ stripePayoutId: `po_${i}`,
20
+ accountId: "acct_a",
21
+ amount: 10,
22
+ currency,
23
+ status: i < 60 ? "paid" : "failed",
24
+ });
25
+ }
26
+ await t.mutation(api.connect.mutations.recordTransferReversal, {
27
+ stripeTransferId: "tr_0",
28
+ reversedAmount: 40,
29
+ });
30
+ await t.mutation(api.connect.mutations.upsertTransfer, {
31
+ stripeTransferId: "tr_restored",
32
+ destinationAccountId: "acct_a",
33
+ amount: 40,
34
+ currency: "usd",
35
+ status: "paid",
36
+ reinstatement: true,
37
+ });
38
+ const earnings = await t.query(
39
+ api.connect.queries.getAccountEarningsByCurrency,
40
+ { destinationAccountId: "acct_a", previewLimit: 2 },
41
+ );
42
+ expect(earnings.transfers).toHaveLength(2);
43
+ expect(earnings.totals).toEqual([
44
+ { currency: "eur", gross: 3000, reversed: 0, net: 3000, transferCount: 30 },
45
+ { currency: "jpy", gross: 3000, reversed: 0, net: 3000, transferCount: 30 },
46
+ { currency: "kwd", gross: 3000, reversed: 0, net: 3000, transferCount: 30 },
47
+ {
48
+ currency: "usd",
49
+ gross: 3040,
50
+ reversed: 40,
51
+ net: 3000,
52
+ transferCount: 31,
53
+ },
54
+ ]);
55
+ const payouts = await t.query(
56
+ api.connect.queries.getAccountPayoutsByCurrency,
57
+ { accountId: "acct_a", previewLimit: 1 },
58
+ );
59
+ expect(payouts.payouts).toHaveLength(1);
60
+ expect(payouts.totals.map((row) => row.paidOut)).toEqual([
61
+ 150, 150, 150, 150,
62
+ ]);
63
+ expect(
64
+ await t.query(api.connect.queries.getAccountEarningsByCurrency, {
65
+ destinationAccountId: "other",
66
+ }),
67
+ ).toEqual({ totals: [], transfers: [] });
68
+ });
69
+ it("refuses to add incompatible currencies in the legacy totals", async () => {
70
+ const t = convexTest(schema, modules);
71
+ for (const currency of ["usd", "eur"]) {
72
+ await t.mutation(api.connect.mutations.upsertTransfer, {
73
+ stripeTransferId: currency,
74
+ destinationAccountId: "acct_a",
75
+ amount: 1,
76
+ currency,
77
+ status: "paid",
78
+ });
79
+ await t.mutation(api.connect.mutations.upsertPayout, {
80
+ stripePayoutId: currency,
81
+ accountId: "acct_a",
82
+ amount: 1,
83
+ currency,
84
+ status: "paid",
85
+ });
86
+ }
87
+ await expect(
88
+ t.query(api.connect.queries.getAccountEarnings, {
89
+ destinationAccountId: "acct_a",
90
+ }),
91
+ ).rejects.toThrow("currency");
92
+ await expect(
93
+ t.query(api.connect.queries.getAccountPayouts, { accountId: "acct_a" }),
94
+ ).rejects.toThrow("currency");
95
+ });
@@ -0,0 +1,59 @@
1
+ import type { Doc } from "../_generated/dataModel.js";
2
+ import type { QueryCtx } from "../_generated/server.js";
3
+
4
+ export async function getAccountEarnings(
5
+ ctx: QueryCtx,
6
+ args: { destinationAccountId: string; previewLimit?: number },
7
+ ) {
8
+ const previewLimit = args.previewLimit ?? 50;
9
+ let currency: string | undefined;
10
+ let gross = 0;
11
+ let reversed = 0;
12
+ let transferCount = 0;
13
+ const transfers: Doc<"transfers">[] = [];
14
+
15
+ for await (const tr of ctx.db
16
+ .query("transfers")
17
+ .withIndex("by_destination_account_id", (q) =>
18
+ q.eq("destinationAccountId", args.destinationAccountId),
19
+ )
20
+ .order("desc")) {
21
+ if (currency !== undefined && currency !== tr.currency)
22
+ throw new Error(
23
+ "Mixed currency account: use getAccountEarningsByCurrency",
24
+ );
25
+ currency = tr.currency;
26
+ gross += tr.amount;
27
+ reversed += tr.reversedAmount ?? 0;
28
+ transferCount += 1;
29
+ if (transfers.length < previewLimit) transfers.push(tr);
30
+ }
31
+
32
+ return { gross, reversed, transferCount, transfers };
33
+ }
34
+
35
+ export async function getAccountPayouts(
36
+ ctx: QueryCtx,
37
+ args: { accountId: string; previewLimit?: number },
38
+ ) {
39
+ const previewLimit = args.previewLimit ?? 50;
40
+ let currency: string | undefined;
41
+ let paidOut = 0;
42
+ let payoutCount = 0;
43
+ const payouts: Doc<"payouts">[] = [];
44
+
45
+ for await (const p of ctx.db
46
+ .query("payouts")
47
+ .withIndex("by_account_id", (q) => q.eq("accountId", args.accountId))) {
48
+ if (currency !== undefined && currency !== p.currency)
49
+ throw new Error(
50
+ "Mixed currency account: use getAccountPayoutsByCurrency",
51
+ );
52
+ currency = p.currency;
53
+ payoutCount += 1;
54
+ if (p.status === "paid") paidOut += p.amount;
55
+ if (payouts.length < previewLimit) payouts.push(p);
56
+ }
57
+
58
+ return { paidOut, payoutCount, payouts };
59
+ }
@@ -0,0 +1,21 @@
1
+ import { paginator } from "convex-helpers/server/pagination";
2
+ import schema from "../schema.js";
3
+ import { v } from "convex/values";
4
+ import { mutation } from "./triggers.js";
5
+ /** Call repeatedly until isDone. Updates and backfill share one transaction. */
6
+ export const backfillCurrencyTotals = mutation({
7
+ args: {
8
+ kind: v.union(v.literal("earnings"), v.literal("payouts")),
9
+ cursor: v.union(v.string(), v.null()),
10
+ },
11
+ returns: v.object({ isDone: v.boolean(), continueCursor: v.string() }),
12
+ handler: async (ctx, args) => {
13
+ const table = args.kind === "earnings" ? "transfers" : "payouts";
14
+ const result = await paginator(ctx.db, schema)
15
+ .query(table)
16
+ .paginate({ numItems: 100, cursor: args.cursor });
17
+ for (const row of result.page)
18
+ if (!row.totalsRecorded) await ctx.db.patch(table, row._id, {});
19
+ return { isDone: result.isDone, continueCursor: result.continueCursor };
20
+ },
21
+ });
@@ -0,0 +1,41 @@
1
+ import type { QueryCtx } from "../_generated/server.js";
2
+ export function previewSize(value = 50) {
3
+ if (!Number.isInteger(value) || value < 0 || value > 100)
4
+ throw new Error("Preview size must be an integer from 0 to 100");
5
+ return value;
6
+ }
7
+ export async function currencyTotals(
8
+ ctx: QueryCtx,
9
+ accountId: string,
10
+ kind: "earnings" | "payouts",
11
+ ) {
12
+ const pending =
13
+ kind === "earnings"
14
+ ? await ctx.db
15
+ .query("transfers")
16
+ .withIndex("by_account_totals_recorded", (q) =>
17
+ q
18
+ .eq("destinationAccountId", accountId)
19
+ .eq("totalsRecorded", undefined),
20
+ )
21
+ .first()
22
+ : await ctx.db
23
+ .query("payouts")
24
+ .withIndex("by_account_totals_recorded", (q) =>
25
+ q.eq("accountId", accountId).eq("totalsRecorded", undefined),
26
+ )
27
+ .first();
28
+ if (pending)
29
+ throw new Error(
30
+ "Currency totals require backfillCurrencyTotals before reading this account",
31
+ );
32
+ const rows = await ctx.db
33
+ .query("currencyTotals")
34
+ .withIndex("by_account_kind_currency", (q) =>
35
+ q.eq("accountId", accountId).eq("kind", kind),
36
+ )
37
+ .take(501);
38
+ if (rows.length > 500)
39
+ throw new Error("Account exceeds supported currency count");
40
+ return rows;
41
+ }
@@ -0,0 +1,11 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ export const currencyTotalsTable = defineTable({
4
+ accountId: v.string(),
5
+ currency: v.string(),
6
+ kind: v.union(v.literal("earnings"), v.literal("payouts")),
7
+ gross: v.number(),
8
+ reversed: v.number(),
9
+ paidOut: v.number(),
10
+ count: v.number(),
11
+ }).index("by_account_kind_currency", ["accountId", "kind", "currency"]);