@fantastic.dev/better-stripe 0.14.0 → 0.16.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 +12 -0
- package/MIGRATION.md +6 -0
- package/README.md +40 -1
- package/dist/client/billing/checkoutOptions.d.ts +27 -0
- package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
- package/dist/client/billing/checkoutOptions.js +2 -0
- package/dist/client/billing/checkoutOptions.js.map +1 -0
- package/dist/client/billing/intents.d.ts +32 -0
- package/dist/client/billing/intents.d.ts.map +1 -0
- package/dist/client/billing/intents.js +71 -0
- package/dist/client/billing/intents.js.map +1 -0
- 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/core/fees.d.ts +2 -0
- package/dist/client/core/fees.d.ts.map +1 -1
- package/dist/client/core/fees.js +7 -0
- package/dist/client/core/fees.js.map +1 -1
- package/dist/client/index.d.ts +18 -26
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +18 -16
- 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 +177 -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 +12 -2
- package/dist/component/connect/schema.d.ts.map +1 -1
- package/dist/component/connect/schema.js +10 -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 +1290 -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/schema.d.ts +31 -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/useConfirmPayment.d.ts +1 -1
- package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
- package/dist/react/hooks/useConfirmPayment.js +13 -2
- package/dist/react/hooks/useConfirmPayment.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 +5 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/workflows/SaveCard.d.ts +27 -0
- package/dist/react/workflows/SaveCard.d.ts.map +1 -0
- package/dist/react/workflows/SaveCard.js +56 -0
- package/dist/react/workflows/SaveCard.js.map +1 -0
- package/docs/react-workflows.md +47 -0
- package/docs/read-contracts.md +136 -0
- package/package.json +3 -2
- package/src/client/billing/checkoutOptions.ts +27 -0
- package/src/client/billing/intents.test.ts +216 -0
- package/src/client/billing/intents.ts +120 -0
- package/src/client/billing/intentsTransport.test.ts +94 -0
- package/src/client/connect/payoutSnapshot.test.ts +27 -0
- package/src/client/connect/payoutSnapshot.ts +4 -0
- package/src/client/core/fees.ts +8 -0
- package/src/client/index.ts +40 -43
- package/src/client/readContracts.ts +90 -0
- package/src/client/readPagination.test.ts +62 -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 +8 -0
- package/src/component/_generated/component.ts +201 -0
- package/src/component/connect/mutations.ts +8 -8
- package/src/component/connect/queries.ts +110 -48
- package/src/component/connect/schema.ts +10 -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/schema.ts +2 -0
- package/src/react/components/EarningsSummary.tsx +5 -2
- package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
- package/src/react/hooks/useConfirmPayment.ts +16 -2
- package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
- package/src/react/hooks/useCurrencyEarnings.ts +73 -0
- package/src/react/index.ts +15 -0
- package/src/react/read-contracts.render.test.tsx +20 -0
- package/src/react/workflows/SaveCard.test.tsx +127 -0
- package/src/react/workflows/SaveCard.tsx +117 -0
|
@@ -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"]);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Triggers } from "convex-helpers/server/triggers";
|
|
2
|
+
import {
|
|
3
|
+
customCtx,
|
|
4
|
+
customMutation,
|
|
5
|
+
} from "convex-helpers/server/customFunctions";
|
|
6
|
+
import type { DataModel } from "../_generated/dataModel.js";
|
|
7
|
+
import {
|
|
8
|
+
mutation as rawMutation,
|
|
9
|
+
type MutationCtx,
|
|
10
|
+
} from "../_generated/server.js";
|
|
11
|
+
|
|
12
|
+
type Contribution = {
|
|
13
|
+
accountId: string;
|
|
14
|
+
currency: string;
|
|
15
|
+
gross: number;
|
|
16
|
+
reversed: number;
|
|
17
|
+
paidOut: number;
|
|
18
|
+
count: number;
|
|
19
|
+
};
|
|
20
|
+
async function adjust(
|
|
21
|
+
ctx: MutationCtx,
|
|
22
|
+
kind: "earnings" | "payouts",
|
|
23
|
+
value: Contribution,
|
|
24
|
+
sign: number,
|
|
25
|
+
) {
|
|
26
|
+
const row = await ctx.db
|
|
27
|
+
.query("currencyTotals")
|
|
28
|
+
.withIndex("by_account_kind_currency", (q) =>
|
|
29
|
+
q
|
|
30
|
+
.eq("accountId", value.accountId)
|
|
31
|
+
.eq("kind", kind)
|
|
32
|
+
.eq("currency", value.currency),
|
|
33
|
+
)
|
|
34
|
+
.unique();
|
|
35
|
+
const totals = {
|
|
36
|
+
gross: (row?.gross ?? 0) + sign * value.gross,
|
|
37
|
+
reversed: (row?.reversed ?? 0) + sign * value.reversed,
|
|
38
|
+
paidOut: (row?.paidOut ?? 0) + sign * value.paidOut,
|
|
39
|
+
count: (row?.count ?? 0) + sign * value.count,
|
|
40
|
+
};
|
|
41
|
+
if (row) {
|
|
42
|
+
if (totals.count === 0) await ctx.db.delete("currencyTotals", row._id);
|
|
43
|
+
else await ctx.db.patch("currencyTotals", row._id, totals);
|
|
44
|
+
} else
|
|
45
|
+
await ctx.db.insert("currencyTotals", {
|
|
46
|
+
accountId: value.accountId,
|
|
47
|
+
currency: value.currency,
|
|
48
|
+
kind,
|
|
49
|
+
...totals,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const triggers = new Triggers<DataModel>();
|
|
53
|
+
triggers.register("transfers", async (ctx, change) => {
|
|
54
|
+
const contribution = (
|
|
55
|
+
row: NonNullable<typeof change.newDoc>,
|
|
56
|
+
): Contribution => ({
|
|
57
|
+
accountId: row.destinationAccountId,
|
|
58
|
+
currency: row.currency,
|
|
59
|
+
gross: row.amount,
|
|
60
|
+
reversed: row.reversedAmount ?? 0,
|
|
61
|
+
paidOut: 0,
|
|
62
|
+
count: 1,
|
|
63
|
+
});
|
|
64
|
+
if (change.oldDoc?.totalsRecorded)
|
|
65
|
+
await adjust(ctx, "earnings", contribution(change.oldDoc), -1);
|
|
66
|
+
if (change.newDoc) {
|
|
67
|
+
await adjust(ctx, "earnings", contribution(change.newDoc), 1);
|
|
68
|
+
if (!change.newDoc.totalsRecorded)
|
|
69
|
+
await ctx.innerDb.patch("transfers", change.id, { totalsRecorded: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
triggers.register("payouts", async (ctx, change) => {
|
|
73
|
+
const contribution = (
|
|
74
|
+
row: NonNullable<typeof change.newDoc>,
|
|
75
|
+
): Contribution => ({
|
|
76
|
+
accountId: row.accountId,
|
|
77
|
+
currency: row.currency,
|
|
78
|
+
gross: 0,
|
|
79
|
+
reversed: 0,
|
|
80
|
+
paidOut: row.status === "paid" ? row.amount : 0,
|
|
81
|
+
count: 1,
|
|
82
|
+
});
|
|
83
|
+
if (change.oldDoc?.totalsRecorded)
|
|
84
|
+
await adjust(ctx, "payouts", contribution(change.oldDoc), -1);
|
|
85
|
+
if (change.newDoc) {
|
|
86
|
+
await adjust(ctx, "payouts", contribution(change.newDoc), 1);
|
|
87
|
+
if (!change.newDoc.totalsRecorded)
|
|
88
|
+
await ctx.innerDb.patch("payouts", change.id, { totalsRecorded: true });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
2
|
+
import schema from "../schema.js";
|
|
3
|
+
import {
|
|
4
|
+
paginationOptsValidator,
|
|
5
|
+
paginationResultValidator,
|
|
6
|
+
} from "convex/server";
|
|
1
7
|
import { v } from "convex/values";
|
|
2
8
|
|
|
3
9
|
import { query } from "../_generated/server";
|
|
@@ -147,3 +153,71 @@ export const listPricesByProduct = query({
|
|
|
147
153
|
.collect();
|
|
148
154
|
},
|
|
149
155
|
});
|
|
156
|
+
|
|
157
|
+
export const paginateProducts = query({
|
|
158
|
+
args: {
|
|
159
|
+
accountId: v.optional(v.string()),
|
|
160
|
+
active: v.optional(v.boolean()),
|
|
161
|
+
paginationOpts: paginationOptsValidator,
|
|
162
|
+
},
|
|
163
|
+
returns: paginationResultValidator(productDocValidator),
|
|
164
|
+
handler: (ctx, args) => {
|
|
165
|
+
if (
|
|
166
|
+
!Number.isInteger(args.paginationOpts.numItems) ||
|
|
167
|
+
args.paginationOpts.numItems < 1 ||
|
|
168
|
+
args.paginationOpts.numItems > 100
|
|
169
|
+
)
|
|
170
|
+
throw new Error("Page size must be an integer from 1 to 100");
|
|
171
|
+
const { accountId, active } = args;
|
|
172
|
+
const rows = paginator(ctx.db, schema).query("products");
|
|
173
|
+
const scoped =
|
|
174
|
+
accountId !== undefined
|
|
175
|
+
? active !== undefined
|
|
176
|
+
? rows.withIndex("by_account_active", (q) =>
|
|
177
|
+
q.eq("accountId", accountId).eq("active", active),
|
|
178
|
+
)
|
|
179
|
+
: rows.withIndex("by_account_id", (q) => q.eq("accountId", accountId))
|
|
180
|
+
: active !== undefined
|
|
181
|
+
? rows.withIndex("by_active", (q) => q.eq("active", active))
|
|
182
|
+
: rows;
|
|
183
|
+
return scoped.order("desc").paginate({
|
|
184
|
+
...args.paginationOpts,
|
|
185
|
+
maximumRowsRead: 200,
|
|
186
|
+
maximumBytesRead: 1_000_000,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export const paginatePrices = query({
|
|
192
|
+
args: {
|
|
193
|
+
productId: v.optional(v.string()),
|
|
194
|
+
active: v.optional(v.boolean()),
|
|
195
|
+
paginationOpts: paginationOptsValidator,
|
|
196
|
+
},
|
|
197
|
+
returns: paginationResultValidator(priceDocValidator),
|
|
198
|
+
handler: (ctx, args) => {
|
|
199
|
+
if (
|
|
200
|
+
!Number.isInteger(args.paginationOpts.numItems) ||
|
|
201
|
+
args.paginationOpts.numItems < 1 ||
|
|
202
|
+
args.paginationOpts.numItems > 100
|
|
203
|
+
)
|
|
204
|
+
throw new Error("Page size must be an integer from 1 to 100");
|
|
205
|
+
const { productId, active } = args;
|
|
206
|
+
const rows = paginator(ctx.db, schema).query("prices");
|
|
207
|
+
const scoped =
|
|
208
|
+
productId !== undefined
|
|
209
|
+
? active !== undefined
|
|
210
|
+
? rows.withIndex("by_product_active", (q) =>
|
|
211
|
+
q.eq("productId", productId).eq("active", active),
|
|
212
|
+
)
|
|
213
|
+
: rows.withIndex("by_product_id", (q) => q.eq("productId", productId))
|
|
214
|
+
: active !== undefined
|
|
215
|
+
? rows.withIndex("by_active", (q) => q.eq("active", active))
|
|
216
|
+
: rows;
|
|
217
|
+
return scoped.order("desc").paginate({
|
|
218
|
+
...args.paginationOpts,
|
|
219
|
+
maximumRowsRead: 200,
|
|
220
|
+
maximumBytesRead: 1_000_000,
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
});
|
|
@@ -471,7 +471,7 @@ describe("core — clearAllTables", () => {
|
|
|
471
471
|
const result = await t.mutation(api.core.mutations.clearAllTables, {});
|
|
472
472
|
const tableCount = Object.keys(schema.tables).length;
|
|
473
473
|
|
|
474
|
-
expect(result.cleared).toBe(
|
|
474
|
+
expect(result.cleared).toBe(15);
|
|
475
475
|
expect(result.tables).toHaveLength(tableCount);
|
|
476
476
|
|
|
477
477
|
for (const tableName of Object.keys(schema.tables) as TableNames[]) {
|