@fantastic.dev/better-stripe 0.16.0 → 0.17.1
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 +21 -0
- package/README.md +60 -9
- 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 +5 -7
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +36 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +2 -1
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/connect/schema.d.ts +2 -0
- package/dist/component/connect/schema.d.ts.map +1 -1
- package/dist/component/connect/schema.js +9 -0
- package/dist/component/connect/schema.js.map +1 -1
- package/dist/component/earnings/arithmetic.d.ts +3 -0
- package/dist/component/earnings/arithmetic.d.ts.map +1 -0
- package/dist/component/earnings/arithmetic.js +11 -0
- package/dist/component/earnings/arithmetic.js.map +1 -0
- package/dist/component/earnings/reads.d.ts.map +1 -1
- package/dist/component/earnings/reads.js +6 -0
- package/dist/component/earnings/reads.js.map +1 -1
- package/dist/component/earnings/triggers.d.ts +4 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/earnings/triggers.js +17 -12
- package/dist/component/earnings/triggers.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 +2 -0
- 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 +39 -3
- package/docs/read-contracts.md +13 -0
- package/package.json +1 -1
- package/src/client/connect/refunds.ts +28 -0
- package/src/client/index.ts +6 -7
- package/src/client/refund-history.test.ts +67 -0
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +56 -0
- package/src/component/connect/queries.ts +2 -1
- package/src/component/connect/schema.ts +9 -0
- package/src/component/earnings/arithmetic.ts +10 -0
- package/src/component/earnings/reads.ts +6 -0
- package/src/component/earnings/triggers.ts +18 -13
- package/src/component/refund-history.test.ts +198 -0
- package/src/component/refundHistory/queries.ts +99 -0
- package/src/component/summary-arithmetic.test.ts +246 -0
- package/src/react/workflows/Costs.test.tsx +21 -0
- package/src/react/workflows/Costs.tsx +9 -3
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { api } from "./_generated/api.js";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
|
|
7
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
8
|
+
const paginate = api.refundHistory.queries.paginateRefunds;
|
|
9
|
+
afterEach(() => vi.useRealTimers());
|
|
10
|
+
const rows = [
|
|
11
|
+
{
|
|
12
|
+
stripeRefundId: "re_a1",
|
|
13
|
+
accountId: "acct_a",
|
|
14
|
+
stripePaymentIntentId: "pi_a",
|
|
15
|
+
status: "succeeded",
|
|
16
|
+
currency: "jpy",
|
|
17
|
+
amount: 500,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
stripeRefundId: "re_a2",
|
|
21
|
+
accountId: "acct_a",
|
|
22
|
+
stripePaymentIntentId: "pi_a",
|
|
23
|
+
status: "pending",
|
|
24
|
+
currency: "isk",
|
|
25
|
+
amount: 500,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
stripeRefundId: "re_a3",
|
|
29
|
+
accountId: "acct_a",
|
|
30
|
+
stripePaymentIntentId: "pi_b",
|
|
31
|
+
status: "succeeded",
|
|
32
|
+
currency: "usd",
|
|
33
|
+
amount: 100,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
stripeRefundId: "re_b1",
|
|
37
|
+
accountId: "acct_b",
|
|
38
|
+
stripePaymentIntentId: "pi_a",
|
|
39
|
+
status: "succeeded",
|
|
40
|
+
currency: "eur",
|
|
41
|
+
amount: 200,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
stripeRefundId: "re_b2",
|
|
45
|
+
accountId: "acct_b",
|
|
46
|
+
stripePaymentIntentId: "pi_only_b",
|
|
47
|
+
status: "failed",
|
|
48
|
+
currency: "usd",
|
|
49
|
+
amount: 300,
|
|
50
|
+
},
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
async function fixture() {
|
|
54
|
+
const t = convexTest(schema, modules);
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
for (const [index, row] of rows.entries()) {
|
|
57
|
+
vi.setSystemTime(1800000000000 + index * 1000);
|
|
58
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
59
|
+
...row,
|
|
60
|
+
stripeChargeId: `ch_${index}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it("walks every original refund over bounded newest-first pages and replays a cursor", async () => {
|
|
67
|
+
const t = await fixture();
|
|
68
|
+
const first = await t.query(paginate, {
|
|
69
|
+
paginationOpts: { numItems: 2, cursor: null },
|
|
70
|
+
});
|
|
71
|
+
expect(
|
|
72
|
+
first.page.map((row: { stripeRefundId: string }) => row.stripeRefundId),
|
|
73
|
+
).toEqual(["re_b2", "re_b1"]);
|
|
74
|
+
expect(first.isDone).toBe(false);
|
|
75
|
+
const args = {
|
|
76
|
+
paginationOpts: { numItems: 2, cursor: first.continueCursor },
|
|
77
|
+
};
|
|
78
|
+
const second = await t.query(paginate, args);
|
|
79
|
+
expect(await t.query(paginate, args)).toEqual(second);
|
|
80
|
+
const last = await t.query(paginate, {
|
|
81
|
+
paginationOpts: { numItems: 2, cursor: second.continueCursor },
|
|
82
|
+
});
|
|
83
|
+
expect(last.isDone).toBe(true);
|
|
84
|
+
expect(
|
|
85
|
+
[...first.page, ...second.page, ...last.page].map((row) => ({
|
|
86
|
+
stripeRefundId: row.stripeRefundId,
|
|
87
|
+
accountId: row.accountId,
|
|
88
|
+
stripePaymentIntentId: row.stripePaymentIntentId,
|
|
89
|
+
status: row.status,
|
|
90
|
+
currency: row.currency,
|
|
91
|
+
amount: row.amount,
|
|
92
|
+
})),
|
|
93
|
+
).toEqual([...rows].reverse());
|
|
94
|
+
expect(last.page[0].stripeChargeId).toBe("ch_0");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.each([
|
|
98
|
+
[{ accountId: "acct_a" }, ["re_a3", "re_a2", "re_a1"]],
|
|
99
|
+
[{ stripePaymentIntentId: "pi_a" }, ["re_b1", "re_a2", "re_a1"]],
|
|
100
|
+
[{ status: "succeeded" }, ["re_b1", "re_a3", "re_a1"]],
|
|
101
|
+
[{ accountId: "acct_a", status: "succeeded" }, ["re_a3", "re_a1"]],
|
|
102
|
+
[{ stripePaymentIntentId: "pi_a", status: "succeeded" }, ["re_b1", "re_a1"]],
|
|
103
|
+
[{ accountId: "acct_a", stripePaymentIntentId: "pi_a" }, ["re_a2", "re_a1"]],
|
|
104
|
+
[
|
|
105
|
+
{ accountId: "acct_a", stripePaymentIntentId: "pi_a", status: "succeeded" },
|
|
106
|
+
["re_a1"],
|
|
107
|
+
],
|
|
108
|
+
[{ accountId: "acct_a", stripePaymentIntentId: "pi_only_b" }, []],
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
accountId: "acct_a",
|
|
112
|
+
stripePaymentIntentId: "pi_only_b",
|
|
113
|
+
status: "failed",
|
|
114
|
+
},
|
|
115
|
+
[],
|
|
116
|
+
],
|
|
117
|
+
] as const)(
|
|
118
|
+
"applies every supplied scope conjunctively: %j",
|
|
119
|
+
async (scope, expected) => {
|
|
120
|
+
const t = await fixture();
|
|
121
|
+
const result = await t.query(paginate, {
|
|
122
|
+
...scope,
|
|
123
|
+
paginationOpts: { numItems: 100, cursor: null },
|
|
124
|
+
});
|
|
125
|
+
expect(
|
|
126
|
+
result.page.map((row: { stripeRefundId: string }) => row.stripeRefundId),
|
|
127
|
+
).toEqual(expected);
|
|
128
|
+
expect(result.isDone).toBe(true);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
it.each([0, -1, 1.5, 101, Number.NaN, Number.POSITIVE_INFINITY])(
|
|
133
|
+
"rejects invalid page size %s",
|
|
134
|
+
async (numItems) => {
|
|
135
|
+
const t = convexTest(schema, modules);
|
|
136
|
+
await expect(
|
|
137
|
+
t.query(paginate, { paginationOpts: { numItems, cursor: null } }),
|
|
138
|
+
).rejects.toThrow(/page size/i);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("returns a completed empty page on empty history", async () => {
|
|
143
|
+
const t = convexTest(schema, modules);
|
|
144
|
+
expect(
|
|
145
|
+
await t.query(paginate, { paginationOpts: { numItems: 1, cursor: null } }),
|
|
146
|
+
).toMatchObject({
|
|
147
|
+
page: [],
|
|
148
|
+
isDone: true,
|
|
149
|
+
continueCursor: expect.any(String),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("pins reactive refund ranges and splits growth before bounded read limits", async () => {
|
|
154
|
+
const t = await fixture();
|
|
155
|
+
const scope = { accountId: "acct_a" };
|
|
156
|
+
const first = await t.query(paginate, {
|
|
157
|
+
...scope,
|
|
158
|
+
paginationOpts: { numItems: 2, cursor: null },
|
|
159
|
+
});
|
|
160
|
+
const second = await t.query(paginate, {
|
|
161
|
+
...scope,
|
|
162
|
+
paginationOpts: { numItems: 2, cursor: first.continueCursor },
|
|
163
|
+
});
|
|
164
|
+
vi.setSystemTime(1800000010000);
|
|
165
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
166
|
+
stripeRefundId: "re_new",
|
|
167
|
+
...scope,
|
|
168
|
+
amount: 500,
|
|
169
|
+
currency: "jpy",
|
|
170
|
+
status: "pending",
|
|
171
|
+
});
|
|
172
|
+
const paginationOpts = {
|
|
173
|
+
numItems: 2,
|
|
174
|
+
cursor: null,
|
|
175
|
+
endCursor: first.continueCursor,
|
|
176
|
+
};
|
|
177
|
+
const pinned = await t.query(paginate, { ...scope, paginationOpts });
|
|
178
|
+
expect(pinned.page).toHaveLength(3);
|
|
179
|
+
expect(
|
|
180
|
+
new Set([...pinned.page, ...second.page].map((row) => row.stripeRefundId))
|
|
181
|
+
.size,
|
|
182
|
+
).toBe(4);
|
|
183
|
+
for (let index = 0; index < 205; index++) {
|
|
184
|
+
vi.setSystemTime(1800000020000 + index * 1000);
|
|
185
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
186
|
+
stripeRefundId: `re_growth_${index}`,
|
|
187
|
+
...scope,
|
|
188
|
+
amount: 500,
|
|
189
|
+
currency: "jpy",
|
|
190
|
+
status: "succeeded",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const grown = await t.query(paginate, { ...scope, paginationOpts });
|
|
194
|
+
expect(grown.page).toHaveLength(200);
|
|
195
|
+
expect(grown.pageStatus).toBe("SplitRequired");
|
|
196
|
+
expect(grown.splitCursor).toBeTruthy();
|
|
197
|
+
expect(grown.page.every((row) => row.accountId === "acct_a")).toBe(true);
|
|
198
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
paginationOptsValidator,
|
|
3
|
+
paginationResultValidator,
|
|
4
|
+
} from "convex/server";
|
|
5
|
+
import { v } from "convex/values";
|
|
6
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
7
|
+
|
|
8
|
+
import { query, type QueryCtx } from "../_generated/server.js";
|
|
9
|
+
import schema from "../schema.js";
|
|
10
|
+
import {
|
|
11
|
+
refundDocValidator,
|
|
12
|
+
refundStatusValidator,
|
|
13
|
+
type RefundStatus,
|
|
14
|
+
} from "../connect/validators.js";
|
|
15
|
+
|
|
16
|
+
/** Host-authorized scopes are conjunctive; use the package React pagination binding. */
|
|
17
|
+
export const paginateRefunds = query({
|
|
18
|
+
args: {
|
|
19
|
+
accountId: v.optional(v.string()),
|
|
20
|
+
stripePaymentIntentId: v.optional(v.string()),
|
|
21
|
+
status: v.optional(refundStatusValidator),
|
|
22
|
+
paginationOpts: paginationOptsValidator,
|
|
23
|
+
},
|
|
24
|
+
returns: paginationResultValidator(refundDocValidator),
|
|
25
|
+
handler: (ctx, args) => {
|
|
26
|
+
const { paginationOpts } = args;
|
|
27
|
+
if (
|
|
28
|
+
!Number.isSafeInteger(paginationOpts.numItems) ||
|
|
29
|
+
paginationOpts.numItems < 1 ||
|
|
30
|
+
paginationOpts.numItems > 100
|
|
31
|
+
)
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Refund history page size must be an integer from 1 to 100",
|
|
34
|
+
);
|
|
35
|
+
return scopedRefunds(ctx, args)
|
|
36
|
+
.order("desc")
|
|
37
|
+
.paginate({
|
|
38
|
+
...paginationOpts,
|
|
39
|
+
maximumRowsRead: 200,
|
|
40
|
+
maximumBytesRead: 1_000_000,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function scopedRefunds(
|
|
46
|
+
ctx: QueryCtx,
|
|
47
|
+
args: {
|
|
48
|
+
accountId?: string;
|
|
49
|
+
stripePaymentIntentId?: string;
|
|
50
|
+
status?: RefundStatus;
|
|
51
|
+
},
|
|
52
|
+
) {
|
|
53
|
+
const { accountId, stripePaymentIntentId, status } = args;
|
|
54
|
+
const refunds = paginator(ctx.db, schema).query("refunds");
|
|
55
|
+
if (
|
|
56
|
+
accountId !== undefined &&
|
|
57
|
+
stripePaymentIntentId !== undefined &&
|
|
58
|
+
status === undefined
|
|
59
|
+
)
|
|
60
|
+
return refunds.withIndex(
|
|
61
|
+
"by_account_id_and_stripe_payment_intent_id",
|
|
62
|
+
(q) =>
|
|
63
|
+
q
|
|
64
|
+
.eq("accountId", accountId)
|
|
65
|
+
.eq("stripePaymentIntentId", stripePaymentIntentId),
|
|
66
|
+
);
|
|
67
|
+
if (
|
|
68
|
+
accountId !== undefined &&
|
|
69
|
+
stripePaymentIntentId !== undefined &&
|
|
70
|
+
status !== undefined
|
|
71
|
+
)
|
|
72
|
+
return refunds.withIndex(
|
|
73
|
+
"by_account_id_and_stripe_payment_intent_id_and_status",
|
|
74
|
+
(q) =>
|
|
75
|
+
q
|
|
76
|
+
.eq("accountId", accountId)
|
|
77
|
+
.eq("stripePaymentIntentId", stripePaymentIntentId)
|
|
78
|
+
.eq("status", status),
|
|
79
|
+
);
|
|
80
|
+
if (accountId !== undefined && status !== undefined)
|
|
81
|
+
return refunds.withIndex("by_account_id_and_status", (q) =>
|
|
82
|
+
q.eq("accountId", accountId).eq("status", status),
|
|
83
|
+
);
|
|
84
|
+
if (stripePaymentIntentId !== undefined && status !== undefined)
|
|
85
|
+
return refunds.withIndex("by_payment_intent_id_and_status", (q) =>
|
|
86
|
+
q.eq("stripePaymentIntentId", stripePaymentIntentId).eq("status", status),
|
|
87
|
+
);
|
|
88
|
+
if (accountId !== undefined)
|
|
89
|
+
return refunds.withIndex("by_account_id", (q) =>
|
|
90
|
+
q.eq("accountId", accountId),
|
|
91
|
+
);
|
|
92
|
+
if (stripePaymentIntentId !== undefined)
|
|
93
|
+
return refunds.withIndex("by_stripe_payment_intent_id", (q) =>
|
|
94
|
+
q.eq("stripePaymentIntentId", stripePaymentIntentId),
|
|
95
|
+
);
|
|
96
|
+
if (status !== undefined)
|
|
97
|
+
return refunds.withIndex("by_status", (q) => q.eq("status", status));
|
|
98
|
+
return refunds;
|
|
99
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { api } from "./_generated/api.js";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
|
|
7
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
8
|
+
const max = Number.MAX_SAFE_INTEGER;
|
|
9
|
+
const transfer = (stripeTransferId: string, amount: number) => ({
|
|
10
|
+
stripeTransferId,
|
|
11
|
+
amount,
|
|
12
|
+
destinationAccountId: "acct_a",
|
|
13
|
+
currency: "usd",
|
|
14
|
+
status: "paid" as const,
|
|
15
|
+
});
|
|
16
|
+
const payout = (stripePayoutId: string, amount: number) => ({
|
|
17
|
+
stripePayoutId,
|
|
18
|
+
amount,
|
|
19
|
+
accountId: "acct_a",
|
|
20
|
+
currency: "usd",
|
|
21
|
+
status: "paid" as const,
|
|
22
|
+
});
|
|
23
|
+
const earnings = { destinationAccountId: "acct_a", previewLimit: 0 };
|
|
24
|
+
|
|
25
|
+
it("rejects the public artifact overflow before rounding and rolls back each rejected transfer", async () => {
|
|
26
|
+
const t = convexTest(schema, modules);
|
|
27
|
+
await t.mutation(
|
|
28
|
+
api.connect.mutations.upsertTransfer,
|
|
29
|
+
transfer("tr_max", max),
|
|
30
|
+
);
|
|
31
|
+
const original = await t.query(
|
|
32
|
+
api.connect.queries.getAccountEarningsByCurrency,
|
|
33
|
+
earnings,
|
|
34
|
+
);
|
|
35
|
+
for (const id of ["tr_overflow_1", "tr_overflow_2"])
|
|
36
|
+
await expect(
|
|
37
|
+
t.mutation(api.connect.mutations.upsertTransfer, transfer(id, 1)),
|
|
38
|
+
).rejects.toThrow(/safe integer/i);
|
|
39
|
+
expect(
|
|
40
|
+
await t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
41
|
+
).toEqual(original);
|
|
42
|
+
expect(await t.run((ctx) => ctx.db.query("transfers").take(3))).toHaveLength(
|
|
43
|
+
1,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects paid payout overflow without persisting the payout or changing its summary", async () => {
|
|
48
|
+
const t = convexTest(schema, modules);
|
|
49
|
+
await t.mutation(api.connect.mutations.upsertPayout, payout("po_max", max));
|
|
50
|
+
await expect(
|
|
51
|
+
t.mutation(api.connect.mutations.upsertPayout, payout("po_overflow", 1)),
|
|
52
|
+
).rejects.toThrow(/safe integer/i);
|
|
53
|
+
expect(
|
|
54
|
+
await t.query(api.connect.queries.getAccountPayoutsByCurrency, {
|
|
55
|
+
accountId: "acct_a",
|
|
56
|
+
previewLimit: 0,
|
|
57
|
+
}),
|
|
58
|
+
).toMatchObject({ totals: [{ paidOut: max, payoutCount: 1 }] });
|
|
59
|
+
expect(await t.run((ctx) => ctx.db.query("payouts").take(2))).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each([0.5, Number.NaN, Number.POSITIVE_INFINITY, max + 1])(
|
|
63
|
+
"rejects invalid source amounts %s, including unpaid payouts",
|
|
64
|
+
async (amount) => {
|
|
65
|
+
const t = convexTest(schema, modules);
|
|
66
|
+
await expect(
|
|
67
|
+
t.mutation(
|
|
68
|
+
api.connect.mutations.upsertTransfer,
|
|
69
|
+
transfer("tr_invalid", amount),
|
|
70
|
+
),
|
|
71
|
+
).rejects.toThrow(/safe integer/i);
|
|
72
|
+
await expect(
|
|
73
|
+
t.mutation(api.connect.mutations.upsertPayout, {
|
|
74
|
+
...payout("po_invalid", amount),
|
|
75
|
+
status: "pending",
|
|
76
|
+
}),
|
|
77
|
+
).rejects.toThrow(/safe integer/i);
|
|
78
|
+
expect(
|
|
79
|
+
await t.run((ctx) => ctx.db.query("currencyTotals").take(1)),
|
|
80
|
+
).toEqual([]);
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
it("rolls back subtract/reapply updates that would overflow", async () => {
|
|
85
|
+
const t = convexTest(schema, modules);
|
|
86
|
+
await t.mutation(
|
|
87
|
+
api.connect.mutations.upsertTransfer,
|
|
88
|
+
transfer("tr_max", max),
|
|
89
|
+
);
|
|
90
|
+
await t.mutation(
|
|
91
|
+
api.connect.mutations.upsertTransfer,
|
|
92
|
+
transfer("tr_update", 0),
|
|
93
|
+
);
|
|
94
|
+
const before = await t.run((ctx) => ctx.db.query("transfers").take(3));
|
|
95
|
+
await expect(
|
|
96
|
+
t.mutation(api.connect.mutations.upsertTransfer, transfer("tr_update", 1)),
|
|
97
|
+
).rejects.toThrow(/safe integer/i);
|
|
98
|
+
expect(await t.run((ctx) => ctx.db.query("transfers").take(3))).toEqual(
|
|
99
|
+
before,
|
|
100
|
+
);
|
|
101
|
+
expect(
|
|
102
|
+
await t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
103
|
+
).toMatchObject({ totals: [{ gross: max, transferCount: 2 }] });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rolls back a reversal whose cumulative summary would overflow", async () => {
|
|
107
|
+
const t = convexTest(schema, modules);
|
|
108
|
+
await t.mutation(api.connect.mutations.upsertTransfer, {
|
|
109
|
+
...transfer("tr_a", 1),
|
|
110
|
+
reversedAmount: max - 1,
|
|
111
|
+
});
|
|
112
|
+
await t.mutation(api.connect.mutations.upsertTransfer, {
|
|
113
|
+
...transfer("tr_b", 1),
|
|
114
|
+
reversedAmount: 1,
|
|
115
|
+
});
|
|
116
|
+
const before = await t.query(
|
|
117
|
+
api.connect.queries.getAccountEarningsByCurrency,
|
|
118
|
+
earnings,
|
|
119
|
+
);
|
|
120
|
+
await expect(
|
|
121
|
+
t.mutation(api.connect.mutations.recordTransferReversal, {
|
|
122
|
+
stripeTransferId: "tr_b",
|
|
123
|
+
reversedAmount: 2,
|
|
124
|
+
}),
|
|
125
|
+
).rejects.toThrow(/safe integer/i);
|
|
126
|
+
expect(
|
|
127
|
+
await t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
128
|
+
).toEqual(before);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it.each(["earnings", "payouts"] as const)(
|
|
132
|
+
"rolls back an entire %s backfill page and source markers on unsafe totals",
|
|
133
|
+
async (kind) => {
|
|
134
|
+
const t = convexTest(schema, modules);
|
|
135
|
+
await t.run(async (ctx) => {
|
|
136
|
+
if (kind === "earnings") {
|
|
137
|
+
await ctx.db.insert("transfers", transfer("tr_legacy_max", max));
|
|
138
|
+
await ctx.db.insert("transfers", transfer("tr_legacy_overflow", 1));
|
|
139
|
+
} else {
|
|
140
|
+
await ctx.db.insert("payouts", payout("po_legacy_max", max));
|
|
141
|
+
await ctx.db.insert("payouts", payout("po_legacy_overflow", 1));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
await expect(
|
|
145
|
+
t.mutation(api.earnings.mutations.backfillCurrencyTotals, {
|
|
146
|
+
kind,
|
|
147
|
+
cursor: null,
|
|
148
|
+
}),
|
|
149
|
+
).rejects.toThrow(/safe integer/i);
|
|
150
|
+
expect(
|
|
151
|
+
await t.run((ctx) => ctx.db.query("currencyTotals").take(1)),
|
|
152
|
+
).toEqual([]);
|
|
153
|
+
expect(
|
|
154
|
+
(
|
|
155
|
+
await t.run((ctx) =>
|
|
156
|
+
ctx.db.query(kind === "earnings" ? "transfers" : "payouts").take(2),
|
|
157
|
+
)
|
|
158
|
+
).every((row) => row.totalsRecorded === undefined),
|
|
159
|
+
).toBe(true);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it.each([
|
|
164
|
+
["gross", max + 1],
|
|
165
|
+
["reversed", 0.5],
|
|
166
|
+
["paidOut", Number.POSITIVE_INFINITY],
|
|
167
|
+
["count", Number.NaN],
|
|
168
|
+
] as const)(
|
|
169
|
+
"rejects unsafe stored summary field %s before exposing totals",
|
|
170
|
+
async (field, value) => {
|
|
171
|
+
const t = convexTest(schema, modules);
|
|
172
|
+
await t.run((ctx) =>
|
|
173
|
+
ctx.db.insert("currencyTotals", {
|
|
174
|
+
accountId: "acct_a",
|
|
175
|
+
currency: "usd",
|
|
176
|
+
kind: "earnings",
|
|
177
|
+
gross: 100,
|
|
178
|
+
reversed: 0,
|
|
179
|
+
paidOut: 0,
|
|
180
|
+
count: 1,
|
|
181
|
+
[field]: value,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
await expect(
|
|
185
|
+
t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
186
|
+
).rejects.toThrow(/safe integer/i);
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
it("checks summary counts before persisting a new source record", async () => {
|
|
191
|
+
const t = convexTest(schema, modules);
|
|
192
|
+
await t.run((ctx) =>
|
|
193
|
+
ctx.db.insert("currencyTotals", {
|
|
194
|
+
accountId: "acct_a",
|
|
195
|
+
currency: "usd",
|
|
196
|
+
kind: "earnings",
|
|
197
|
+
gross: 0,
|
|
198
|
+
reversed: 0,
|
|
199
|
+
paidOut: 0,
|
|
200
|
+
count: max,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
await expect(
|
|
204
|
+
t.mutation(api.connect.mutations.upsertTransfer, transfer("tr_count", 0)),
|
|
205
|
+
).rejects.toThrow(/safe integer/i);
|
|
206
|
+
expect(await t.run((ctx) => ctx.db.query("transfers").take(1))).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("rejects an unsafe derived net even when both stored operands are safe integers", async () => {
|
|
210
|
+
const t = convexTest(schema, modules);
|
|
211
|
+
await t.run((ctx) =>
|
|
212
|
+
ctx.db.insert("currencyTotals", {
|
|
213
|
+
accountId: "acct_a",
|
|
214
|
+
currency: "usd",
|
|
215
|
+
kind: "earnings",
|
|
216
|
+
gross: max,
|
|
217
|
+
reversed: -1,
|
|
218
|
+
paidOut: 0,
|
|
219
|
+
count: 1,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
await expect(
|
|
223
|
+
t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
224
|
+
).rejects.toThrow(/safe integer/i);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("preserves supported boundary amounts through reversal and reinstatement", async () => {
|
|
228
|
+
const t = convexTest(schema, modules);
|
|
229
|
+
await t.mutation(
|
|
230
|
+
api.connect.mutations.upsertTransfer,
|
|
231
|
+
transfer("tr_boundary", max - 2),
|
|
232
|
+
);
|
|
233
|
+
await t.mutation(api.connect.mutations.recordTransferReversal, {
|
|
234
|
+
stripeTransferId: "tr_boundary",
|
|
235
|
+
reversedAmount: 1,
|
|
236
|
+
});
|
|
237
|
+
await t.mutation(api.connect.mutations.upsertTransfer, {
|
|
238
|
+
...transfer("tr_restore", 1),
|
|
239
|
+
reinstatement: true,
|
|
240
|
+
});
|
|
241
|
+
expect(
|
|
242
|
+
await t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
243
|
+
).toMatchObject({
|
|
244
|
+
totals: [{ gross: max - 1, reversed: 1, net: max - 2, transferCount: 2 }],
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -39,3 +39,24 @@ it("renders chargeback economics and explicitly handles missing charge links", (
|
|
|
39
39
|
expect(screen.getByText("$80.00")).toBeTruthy();
|
|
40
40
|
expect(screen.getByText("No transfer rows found.")).toBeTruthy();
|
|
41
41
|
});
|
|
42
|
+
it("distinguishes reserved recovery from money already collected", () => {
|
|
43
|
+
render(
|
|
44
|
+
<CostRecovery
|
|
45
|
+
rows={[
|
|
46
|
+
{
|
|
47
|
+
paymentIntentId: "pi_reserved",
|
|
48
|
+
amountDue: 1000,
|
|
49
|
+
collectedAmount: 400,
|
|
50
|
+
invoicedAmount: 999,
|
|
51
|
+
reservedAmount: 300,
|
|
52
|
+
outstanding: 300,
|
|
53
|
+
currency: "usd",
|
|
54
|
+
},
|
|
55
|
+
]}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByText("Collected")).toBeTruthy();
|
|
59
|
+
expect(screen.getByText("Reserved")).toBeTruthy();
|
|
60
|
+
expect(screen.queryByText("$9.99")).toBeNull();
|
|
61
|
+
expect(screen.getAllByText("$3.00")).toHaveLength(2);
|
|
62
|
+
});
|
|
@@ -10,24 +10,30 @@ export type CostRecoveryRow = {
|
|
|
10
10
|
amountDue: number;
|
|
11
11
|
collectedAmount: number;
|
|
12
12
|
invoicedAmount: number;
|
|
13
|
+
reservedAmount?: number;
|
|
13
14
|
outstanding: number;
|
|
14
15
|
currency: string;
|
|
15
16
|
};
|
|
16
17
|
export function CostRecovery(props: ResourceCollectionProps<CostRecoveryRow>) {
|
|
18
|
+
const reserved = props.rows?.some((row) => row.reservedAmount !== undefined);
|
|
17
19
|
const columns: WorkflowColumn<CostRecoveryRow>[] = [
|
|
18
20
|
{ id: "payment", label: "Payment", cell: (row) => row.paymentIntentId },
|
|
19
21
|
...(
|
|
20
22
|
[
|
|
21
23
|
["amountDue", "Total cost"],
|
|
22
|
-
["collectedAmount", "Deducted from earnings"],
|
|
23
|
-
[
|
|
24
|
+
["collectedAmount", reserved ? "Collected" : "Deducted from earnings"],
|
|
25
|
+
[
|
|
26
|
+
reserved ? "reservedAmount" : "invoicedAmount",
|
|
27
|
+
reserved ? "Reserved" : "Added to invoice",
|
|
28
|
+
],
|
|
24
29
|
["outstanding", "Outstanding"],
|
|
25
30
|
] as const
|
|
26
31
|
).map(([field, label]) => ({
|
|
27
32
|
id: field,
|
|
28
33
|
label,
|
|
29
34
|
align: "end" as const,
|
|
30
|
-
cell: (row: CostRecoveryRow) =>
|
|
35
|
+
cell: (row: CostRecoveryRow) =>
|
|
36
|
+
formatPrice(row[field] ?? row.invoicedAmount, row.currency),
|
|
31
37
|
})),
|
|
32
38
|
];
|
|
33
39
|
return (
|