@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.
- package/CHANGELOG.md +16 -0
- package/MIGRATION.md +6 -0
- package/README.md +15 -1
- package/dist/client/connect/payoutSnapshot.d.ts +2 -0
- package/dist/client/connect/payoutSnapshot.d.ts.map +1 -1
- package/dist/client/connect/payoutSnapshot.js +2 -0
- package/dist/client/connect/payoutSnapshot.js.map +1 -1
- package/dist/client/connect/refunds.d.ts +12 -0
- package/dist/client/connect/refunds.d.ts.map +1 -1
- package/dist/client/connect/refunds.js +8 -0
- package/dist/client/connect/refunds.js.map +1 -1
- package/dist/client/index.d.ts +8 -10
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +6 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/readContracts.d.ts +50 -0
- package/dist/client/readContracts.d.ts.map +1 -0
- package/dist/client/readContracts.js +24 -0
- package/dist/client/readContracts.js.map +1 -0
- package/dist/client/types/documents.d.ts +2 -0
- package/dist/client/types/documents.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +2 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +213 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/mutations.d.ts +2 -0
- package/dist/component/connect/mutations.d.ts.map +1 -1
- package/dist/component/connect/mutations.js +8 -8
- package/dist/component/connect/mutations.js.map +1 -1
- package/dist/component/connect/queries.d.ts +102 -2
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +88 -45
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/connect/schema.d.ts +14 -2
- package/dist/component/connect/schema.d.ts.map +1 -1
- package/dist/component/connect/schema.js +19 -1
- package/dist/component/connect/schema.js.map +1 -1
- package/dist/component/connect/validators.d.ts +13 -2
- package/dist/component/connect/validators.d.ts.map +1 -1
- package/dist/component/connect/validators.js +4 -0
- package/dist/component/connect/validators.js.map +1 -1
- package/dist/component/earnings/legacyReads.d.ts +51 -0
- package/dist/component/earnings/legacyReads.d.ts.map +1 -0
- package/dist/component/earnings/legacyReads.js +43 -0
- package/dist/component/earnings/legacyReads.js.map +1 -0
- package/dist/component/earnings/mutations.d.ts +9 -0
- package/dist/component/earnings/mutations.d.ts.map +1 -0
- package/dist/component/earnings/mutations.js +23 -0
- package/dist/component/earnings/mutations.js.map +1 -0
- package/dist/component/earnings/reads.d.ts +14 -0
- package/dist/component/earnings/reads.d.ts.map +1 -0
- package/dist/component/earnings/reads.js +28 -0
- package/dist/component/earnings/reads.js.map +1 -0
- package/dist/component/earnings/schema.d.ts +20 -0
- package/dist/component/earnings/schema.d.ts.map +1 -0
- package/dist/component/earnings/schema.js +12 -0
- package/dist/component/earnings/schema.js.map +1 -0
- package/dist/component/earnings/triggers.d.ts +1294 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -0
- package/dist/component/earnings/triggers.js +68 -0
- package/dist/component/earnings/triggers.js.map +1 -0
- package/dist/component/products/queries.d.ts +47 -0
- package/dist/component/products/queries.d.ts.map +1 -1
- package/dist/component/products/queries.js +59 -0
- package/dist/component/products/queries.js.map +1 -1
- package/dist/component/refundHistory/queries.d.ts +28 -0
- package/dist/component/refundHistory/queries.d.ts.map +1 -0
- package/dist/component/refundHistory/queries.js +59 -0
- package/dist/component/refundHistory/queries.js.map +1 -0
- package/dist/component/schema.d.ts +33 -2
- package/dist/component/schema.js +2 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/EarningsSummary.d.ts +3 -1
- package/dist/react/components/EarningsSummary.d.ts.map +1 -1
- package/dist/react/components/EarningsSummary.js +3 -3
- package/dist/react/components/EarningsSummary.js.map +1 -1
- package/dist/react/hooks/useCurrencyEarnings.d.ts +31 -0
- package/dist/react/hooks/useCurrencyEarnings.d.ts.map +1 -0
- package/dist/react/hooks/useCurrencyEarnings.js +30 -0
- package/dist/react/hooks/useCurrencyEarnings.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/workflows/Costs.d.ts +1 -0
- package/dist/react/workflows/Costs.d.ts.map +1 -1
- package/dist/react/workflows/Costs.js +7 -3
- package/dist/react/workflows/Costs.js.map +1 -1
- package/docs/react-workflows.md +40 -0
- package/docs/read-contracts.md +136 -0
- package/package.json +3 -2
- package/src/client/connect/payoutSnapshot.test.ts +27 -0
- package/src/client/connect/payoutSnapshot.ts +4 -0
- package/src/client/connect/refunds.ts +28 -0
- package/src/client/index.ts +10 -11
- package/src/client/readContracts.ts +90 -0
- package/src/client/readPagination.test.ts +62 -0
- package/src/client/refund-history.test.ts +67 -0
- package/src/client/types/documents.ts +2 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +25 -0
- package/src/client/webhooks/processors.ts +2 -0
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +257 -0
- package/src/component/connect/mutations.ts +8 -8
- package/src/component/connect/queries.ts +110 -48
- package/src/component/connect/schema.ts +19 -1
- package/src/component/connect/validators.ts +4 -0
- package/src/component/currency-migration.test.ts +119 -0
- package/src/component/currency-totals.test.ts +95 -0
- package/src/component/earnings/legacyReads.ts +59 -0
- package/src/component/earnings/mutations.ts +21 -0
- package/src/component/earnings/reads.ts +41 -0
- package/src/component/earnings/schema.ts +11 -0
- package/src/component/earnings/triggers.ts +91 -0
- package/src/component/products/queries.ts +74 -0
- package/src/component/public.test.ts +1 -1
- package/src/component/read-pagination.test.ts +193 -0
- package/src/component/refund-history.test.ts +198 -0
- package/src/component/refundHistory/queries.ts +99 -0
- package/src/component/schema.ts +2 -0
- package/src/react/components/EarningsSummary.tsx +5 -2
- package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
- package/src/react/hooks/useCurrencyEarnings.ts +73 -0
- package/src/react/index.ts +12 -0
- package/src/react/read-contracts.render.test.tsx +20 -0
- package/src/react/workflows/Costs.test.tsx +21 -0
- 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:
|
|
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:
|
|
519
|
-
|
|
520
|
-
let paidOut = 0;
|
|
521
|
-
let payoutCount = 0;
|
|
522
|
-
const payouts: Doc<"payouts">[] = [];
|
|
489
|
+
handler: getAccountPayoutsLegacy,
|
|
490
|
+
});
|
|
523
491
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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(
|
|
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"]);
|