@fantastic.dev/better-stripe 0.23.0 → 0.23.2
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/README.md +1 -1
- package/dist/client/connect/payoutAdoption.d.ts.map +1 -1
- package/dist/client/connect/payoutAdoption.js +4 -3
- package/dist/client/connect/payoutAdoption.js.map +1 -1
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +2659 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.js +231 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.js.map +1 -0
- package/dist/client/connect/payoutSync.d.ts +32 -0
- package/dist/client/connect/payoutSync.d.ts.map +1 -0
- package/dist/client/connect/payoutSync.js +48 -0
- package/dist/client/connect/payoutSync.js.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +1263 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.js +62 -0
- package/dist/client/connect/payoutSyncTestHelpers.js.map +1 -0
- package/dist/client/connect/payoutSyncValidation.d.ts +16 -0
- package/dist/client/connect/payoutSyncValidation.d.ts.map +1 -0
- package/dist/client/connect/payoutSyncValidation.js +54 -0
- package/dist/client/connect/payoutSyncValidation.js.map +1 -0
- package/dist/client/index.d.ts +26 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/readContracts.d.ts +8 -0
- package/dist/client/readContracts.d.ts.map +1 -1
- package/dist/client/readContracts.js +7 -0
- package/dist/client/readContracts.js.map +1 -1
- package/dist/component/_generated/component.d.ts +69 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/mutations.d.ts.map +1 -1
- package/dist/component/connect/mutations.js +4 -27
- package/dist/component/connect/mutations.js.map +1 -1
- package/dist/component/connect/payoutWrites.d.ts +21 -0
- package/dist/component/connect/payoutWrites.d.ts.map +1 -0
- package/dist/component/connect/payoutWrites.js +19 -0
- package/dist/component/connect/payoutWrites.js.map +1 -0
- package/dist/component/earnings/triggers.d.ts +44 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutSync/helpers.d.ts +44 -0
- package/dist/component/payoutSync/helpers.d.ts.map +1 -0
- package/dist/component/payoutSync/helpers.js +60 -0
- package/dist/component/payoutSync/helpers.js.map +1 -0
- package/dist/component/payoutSync/mutations.d.ts +51 -0
- package/dist/component/payoutSync/mutations.d.ts.map +1 -0
- package/dist/component/payoutSync/mutations.js +88 -0
- package/dist/component/payoutSync/mutations.js.map +1 -0
- package/dist/component/payoutSync/queries.d.ts +16 -0
- package/dist/component/payoutSync/queries.d.ts.map +1 -0
- package/dist/component/payoutSync/queries.js +11 -0
- package/dist/component/payoutSync/queries.js.map +1 -0
- package/dist/component/payoutSync/schema.d.ts +46 -0
- package/dist/component/payoutSync/schema.d.ts.map +1 -0
- package/dist/component/payoutSync/schema.js +22 -0
- package/dist/component/payoutSync/schema.js.map +1 -0
- package/dist/component/payoutSync/validators.d.ts +137 -0
- package/dist/component/payoutSync/validators.d.ts.map +1 -0
- package/dist/component/payoutSync/validators.js +19 -0
- package/dist/component/payoutSync/validators.js.map +1 -0
- package/dist/component/schema.d.ts +21 -0
- package/dist/component/schema.js +2 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/read-contracts.md +40 -5
- package/docs/settlement.md +6 -0
- package/package.json +1 -1
- package/src/client/connect/payoutAdoption.test.ts +6 -241
- package/src/client/connect/payoutAdoption.ts +4 -3
- package/src/client/connect/payoutAdoptionTestHelpers.ts +253 -0
- package/src/client/connect/payoutReturnedAdoption.test.ts +157 -0
- package/src/client/connect/payoutSync.test.ts +313 -0
- package/src/client/connect/payoutSync.ts +93 -0
- package/src/client/connect/payoutSyncTestHelpers.ts +82 -0
- package/src/client/connect/payoutSyncValidation.ts +74 -0
- package/src/client/index.ts +14 -11
- package/src/client/readContracts.ts +12 -0
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +82 -0
- package/src/component/connect/mutations.ts +4 -30
- package/src/component/connect/payoutWrites.ts +25 -0
- package/src/component/payout-sync.test.ts +117 -0
- package/src/component/payoutSync/helpers.ts +86 -0
- package/src/component/payoutSync/mutations.ts +102 -0
- package/src/component/payoutSync/queries.ts +10 -0
- package/src/component/payoutSync/schema.ts +25 -0
- package/src/component/payoutSync/validators.ts +21 -0
- package/src/component/schema.ts +2 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import { historical, scope } from "./payoutAdoptionTestHelpers.js";
|
|
4
|
+
|
|
5
|
+
it.each([
|
|
6
|
+
["usd", "failed", 0],
|
|
7
|
+
["jpy", "canceled", 25],
|
|
8
|
+
["kwd", "failed", 25],
|
|
9
|
+
] as const)(
|
|
10
|
+
"adopts and replays returned %s principal with %s status and %i fee",
|
|
11
|
+
async (currency, status, fee) => {
|
|
12
|
+
const s = await historical(currency);
|
|
13
|
+
const payout = await s.provider.payouts.retrieve();
|
|
14
|
+
if (!payout) throw new Error("Missing fixture payout");
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
try {
|
|
17
|
+
vi.setSystemTime(Date.now() + 2000);
|
|
18
|
+
const returnedAt = Math.floor(Date.now() / 1000);
|
|
19
|
+
payout.status = status;
|
|
20
|
+
payout.failure_balance_transaction = "txn_return";
|
|
21
|
+
s.setTransactions([
|
|
22
|
+
{
|
|
23
|
+
id: "txn_return",
|
|
24
|
+
source: "po_one",
|
|
25
|
+
amount: 1000,
|
|
26
|
+
fee,
|
|
27
|
+
net: 1000 - fee,
|
|
28
|
+
currency,
|
|
29
|
+
created: returnedAt,
|
|
30
|
+
available_on: returnedAt,
|
|
31
|
+
status: "available",
|
|
32
|
+
type: status === "failed" ? "payout_failure" : "payout_cancel",
|
|
33
|
+
exchange_rate: null,
|
|
34
|
+
},
|
|
35
|
+
...s.transactions(),
|
|
36
|
+
]);
|
|
37
|
+
const expected = {
|
|
38
|
+
status: "adopted",
|
|
39
|
+
oldestHeldAt: returnedAt * 1000,
|
|
40
|
+
claims: [
|
|
41
|
+
{ status: "returned", amount: 1000, currency, providerFee: fee },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
expect(await s.adopt()).toMatchObject(expected);
|
|
45
|
+
expect(await s.adopt()).toMatchObject(expected);
|
|
46
|
+
expect(
|
|
47
|
+
await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
|
|
48
|
+
).toHaveLength(1);
|
|
49
|
+
expect(
|
|
50
|
+
await s.t.run((ctx) =>
|
|
51
|
+
ctx.db.query("payoutSourceAllocations").collect(),
|
|
52
|
+
),
|
|
53
|
+
).toMatchObject([
|
|
54
|
+
{ paidAmount: 0, reservedAmount: 0 },
|
|
55
|
+
{ paidAmount: 0, reservedAmount: 0 },
|
|
56
|
+
]);
|
|
57
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
58
|
+
|
|
59
|
+
// Add later source funding; the returned principal is payable once, net of its fee.
|
|
60
|
+
s.amounts.push(300);
|
|
61
|
+
vi.setSystemTime(Date.now() + 10000);
|
|
62
|
+
const created = Math.floor(Date.now() / 1000);
|
|
63
|
+
await s.t.run(async (ctx) => {
|
|
64
|
+
await ctx.db.insert("transfers", {
|
|
65
|
+
stripeTransferId: "tr_2",
|
|
66
|
+
destinationAccountId: scope.accountId,
|
|
67
|
+
sourceChargeId: "ch_2",
|
|
68
|
+
amount: 300,
|
|
69
|
+
currency,
|
|
70
|
+
status: "paid",
|
|
71
|
+
role: "store",
|
|
72
|
+
});
|
|
73
|
+
const policy = await ctx.db.query("payoutPolicies").first();
|
|
74
|
+
if (!policy) throw new Error("Missing fixture policy");
|
|
75
|
+
await ctx.db.patch("payoutPolicies", policy._id, {
|
|
76
|
+
mode: "custom",
|
|
77
|
+
thresholds: [{ currency, amount: 1 }],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
s.setTransactions([
|
|
81
|
+
...s.transactions(),
|
|
82
|
+
{
|
|
83
|
+
id: "txn_2",
|
|
84
|
+
source: "py_2",
|
|
85
|
+
amount: 300,
|
|
86
|
+
fee: 0,
|
|
87
|
+
net: 300,
|
|
88
|
+
currency,
|
|
89
|
+
created,
|
|
90
|
+
available_on: created,
|
|
91
|
+
status: "available",
|
|
92
|
+
type: "payment",
|
|
93
|
+
exchange_rate: null,
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
expect(await s.run()).toMatchObject({
|
|
97
|
+
status: "updated",
|
|
98
|
+
claim: { status: "paid", amount: 1300 - fee, currency },
|
|
99
|
+
});
|
|
100
|
+
expect(await s.run()).toMatchObject({
|
|
101
|
+
status: "updated",
|
|
102
|
+
claim: { status: "paid", amount: 1300 - fee, currency },
|
|
103
|
+
});
|
|
104
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
105
|
+
} finally {
|
|
106
|
+
vi.useRealTimers();
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it.each(["pending", "wrong_currency", "wrong_principal"] as const)(
|
|
112
|
+
"holds or rejects %s returns without importing partial history",
|
|
113
|
+
async (kind) => {
|
|
114
|
+
const s = await historical();
|
|
115
|
+
const payout = await s.provider.payouts.retrieve();
|
|
116
|
+
if (!payout) throw new Error("Missing fixture payout");
|
|
117
|
+
payout.status = "failed";
|
|
118
|
+
payout.failure_balance_transaction = "txn_return";
|
|
119
|
+
const now = Math.floor(Date.now() / 1000);
|
|
120
|
+
const amount = kind === "wrong_principal" ? 999 : 1000;
|
|
121
|
+
s.setTransactions([
|
|
122
|
+
{
|
|
123
|
+
id: "txn_return",
|
|
124
|
+
source: "po_one",
|
|
125
|
+
amount,
|
|
126
|
+
fee: 0,
|
|
127
|
+
net: amount,
|
|
128
|
+
currency: kind === "wrong_currency" ? "eur" : "usd",
|
|
129
|
+
created: now,
|
|
130
|
+
available_on: now,
|
|
131
|
+
status: kind === "pending" ? "pending" : "available",
|
|
132
|
+
type: "payout_failure",
|
|
133
|
+
exchange_rate: null,
|
|
134
|
+
},
|
|
135
|
+
...s.transactions(),
|
|
136
|
+
]);
|
|
137
|
+
if (kind === "wrong_principal")
|
|
138
|
+
await expect(s.adopt()).rejects.toThrow(
|
|
139
|
+
"Payout transaction receipt identity or principal does not match.",
|
|
140
|
+
);
|
|
141
|
+
else
|
|
142
|
+
expect(await s.adopt()).toMatchObject({
|
|
143
|
+
status: "held",
|
|
144
|
+
reason:
|
|
145
|
+
kind === "pending"
|
|
146
|
+
? "return_pending"
|
|
147
|
+
: "currency_allocation_required",
|
|
148
|
+
});
|
|
149
|
+
expect(
|
|
150
|
+
await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
|
|
151
|
+
).toEqual([]);
|
|
152
|
+
expect(
|
|
153
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
154
|
+
).toEqual([]);
|
|
155
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
156
|
+
},
|
|
157
|
+
);
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import type Stripe from "stripe";
|
|
4
|
+
import { makeFunctionReference } from "convex/server";
|
|
5
|
+
import type { RunCtx } from "../helpers.js";
|
|
6
|
+
import {
|
|
7
|
+
component,
|
|
8
|
+
setup,
|
|
9
|
+
operation,
|
|
10
|
+
type Scope,
|
|
11
|
+
} from "./payoutSyncTestHelpers.js";
|
|
12
|
+
const symbol = Symbol.for("toReferencePath");
|
|
13
|
+
it("persists the current automatic payout under the connected scope without requiring mature funding", async () => {
|
|
14
|
+
const s = await setup();
|
|
15
|
+
expect(await s.run()).toMatchObject({ status: "accepted", changed: true });
|
|
16
|
+
expect(s.retrieve).toHaveBeenCalledWith(
|
|
17
|
+
"po_one",
|
|
18
|
+
{},
|
|
19
|
+
{ stripeAccount: "acct_owner" },
|
|
20
|
+
);
|
|
21
|
+
expect(await s.row()).toMatchObject({
|
|
22
|
+
stripePayoutId: "po_one",
|
|
23
|
+
accountId: "acct_owner",
|
|
24
|
+
amount: 500,
|
|
25
|
+
currency: "jpy",
|
|
26
|
+
status: "paid",
|
|
27
|
+
arrivalDate: "1970-01-01T00:03:20.000Z",
|
|
28
|
+
});
|
|
29
|
+
expect(await s.run()).toMatchObject({ status: "accepted", changed: false });
|
|
30
|
+
expect(
|
|
31
|
+
await s.t.run((ctx) => ctx.db.query("payouts").collect()),
|
|
32
|
+
).toHaveLength(1);
|
|
33
|
+
expect(
|
|
34
|
+
await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
|
|
35
|
+
).toMatchObject({
|
|
36
|
+
accountId: "acct_owner",
|
|
37
|
+
currency: "jpy",
|
|
38
|
+
paidOut: 500,
|
|
39
|
+
count: 1,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
it("allows a later authoritative paid-to-failed transition and clears obsolete failure details", async () => {
|
|
43
|
+
const s = await setup();
|
|
44
|
+
await s.run();
|
|
45
|
+
s.set({
|
|
46
|
+
status: "failed",
|
|
47
|
+
failure_code: "account_closed",
|
|
48
|
+
failure_message: "Bank account closed",
|
|
49
|
+
});
|
|
50
|
+
await s.run();
|
|
51
|
+
expect(await s.row()).toMatchObject({
|
|
52
|
+
status: "failed",
|
|
53
|
+
failureCode: "account_closed",
|
|
54
|
+
});
|
|
55
|
+
expect(
|
|
56
|
+
await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
|
|
57
|
+
).toMatchObject({ paidOut: 0, count: 1 });
|
|
58
|
+
s.set({ status: "pending", failure_code: null, failure_message: null });
|
|
59
|
+
await s.run();
|
|
60
|
+
expect((await s.row())?.failureCode).toBeUndefined();
|
|
61
|
+
expect((await s.row())?.failureMessage).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
it("keeps platform scope explicit and ignores provider metadata as account authority", async () => {
|
|
64
|
+
const s = await setup({ type: "platform" });
|
|
65
|
+
s.set({ automatic: false });
|
|
66
|
+
await s.run();
|
|
67
|
+
expect(s.retrieve).toHaveBeenCalledWith("po_one", {}, {});
|
|
68
|
+
expect(await s.row()).toMatchObject({
|
|
69
|
+
accountId: "platform",
|
|
70
|
+
currency: "jpy",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
function deferred<T>() {
|
|
74
|
+
let resolve!: (value: T) => void;
|
|
75
|
+
const promise = new Promise<T>((done) => {
|
|
76
|
+
resolve = done;
|
|
77
|
+
});
|
|
78
|
+
return { promise, resolve };
|
|
79
|
+
}
|
|
80
|
+
it("supersedes a delayed old pending read after a newer paid observation commits", async () => {
|
|
81
|
+
const s = await setup();
|
|
82
|
+
const old = { ...s.read(), status: "pending" as const };
|
|
83
|
+
const response = deferred<Stripe.Payout>();
|
|
84
|
+
s.retrieve.mockImplementationOnce(() => response.promise);
|
|
85
|
+
const first = s.run();
|
|
86
|
+
await vi.waitFor(() => expect(s.retrieve).toHaveBeenCalledTimes(1));
|
|
87
|
+
expect(await s.run()).toMatchObject({ status: "accepted", generation: 2 });
|
|
88
|
+
response.resolve(old);
|
|
89
|
+
expect(await first).toMatchObject({ status: "superseded", generation: 1 });
|
|
90
|
+
expect(await s.row()).toMatchObject({ status: "paid" });
|
|
91
|
+
expect(
|
|
92
|
+
await s.t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
|
|
93
|
+
).toMatchObject({ status: "accepted", generation: 2, acceptedGeneration: 2 });
|
|
94
|
+
});
|
|
95
|
+
it("a newer failed read holds its generation instead of accepting an older in-flight snapshot", async () => {
|
|
96
|
+
const s = await setup();
|
|
97
|
+
await s.run();
|
|
98
|
+
const response = deferred<Stripe.Payout>();
|
|
99
|
+
s.retrieve.mockImplementationOnce(() => response.promise);
|
|
100
|
+
const first = s.run();
|
|
101
|
+
await vi.waitFor(() => expect(s.retrieve).toHaveBeenCalledTimes(2));
|
|
102
|
+
s.retrieve.mockRejectedValueOnce(new Error("Provider unavailable"));
|
|
103
|
+
await expect(s.run()).rejects.toThrow("Provider unavailable");
|
|
104
|
+
response.resolve({ ...s.read(), status: "pending" });
|
|
105
|
+
expect(await first).toMatchObject({ status: "superseded" });
|
|
106
|
+
expect(await s.row()).toMatchObject({ status: "paid" });
|
|
107
|
+
expect(
|
|
108
|
+
await s.t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
|
|
109
|
+
).toMatchObject({ status: "unknown", generation: 3, acceptedGeneration: 1 });
|
|
110
|
+
expect(await s.run()).toMatchObject({
|
|
111
|
+
status: "accepted",
|
|
112
|
+
generation: 4,
|
|
113
|
+
changed: false,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
it.each(["begin", "finish"] as const)(
|
|
117
|
+
"recovers a lost %s response through a fresh durable generation",
|
|
118
|
+
async (stage) => {
|
|
119
|
+
const s = await setup();
|
|
120
|
+
let isLost = false;
|
|
121
|
+
const ctx = {
|
|
122
|
+
...s.ctx,
|
|
123
|
+
runMutation: async (ref: Record<symbol, string>, args: never) => {
|
|
124
|
+
const saved = await s.mutate(ref, args);
|
|
125
|
+
if (!isLost && ref[symbol].endsWith(`/payoutSync/mutations/${stage}`)) {
|
|
126
|
+
isLost = true;
|
|
127
|
+
throw new Error("Lost component response");
|
|
128
|
+
}
|
|
129
|
+
return saved;
|
|
130
|
+
},
|
|
131
|
+
} as unknown as RunCtx;
|
|
132
|
+
const sync = await operation();
|
|
133
|
+
await expect(
|
|
134
|
+
sync(s.provider, component, ctx, {
|
|
135
|
+
stripePayoutId: "po_one",
|
|
136
|
+
scope: s.scope,
|
|
137
|
+
}),
|
|
138
|
+
).rejects.toThrow("Lost component response");
|
|
139
|
+
expect(
|
|
140
|
+
await s.t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
|
|
141
|
+
).toMatchObject({
|
|
142
|
+
generation: 1,
|
|
143
|
+
status: stage === "finish" ? "accepted" : "pending",
|
|
144
|
+
});
|
|
145
|
+
expect(await s.run()).toMatchObject({
|
|
146
|
+
status: "accepted",
|
|
147
|
+
generation: 2,
|
|
148
|
+
changed: stage === "begin",
|
|
149
|
+
});
|
|
150
|
+
expect(
|
|
151
|
+
await s.t.run((ctx) => ctx.db.query("payouts").collect()),
|
|
152
|
+
).toHaveLength(1);
|
|
153
|
+
expect(
|
|
154
|
+
await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
|
|
155
|
+
).toMatchObject({ paidOut: 500, count: 1 });
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
it("rejects a foreign existing payout before requesting provider state", async () => {
|
|
159
|
+
const s = await setup();
|
|
160
|
+
await s.t.mutation(
|
|
161
|
+
makeFunctionReference<"mutation">("connect/mutations:upsertPayout"),
|
|
162
|
+
{
|
|
163
|
+
stripePayoutId: "po_one",
|
|
164
|
+
accountId: "acct_foreign",
|
|
165
|
+
amount: 500,
|
|
166
|
+
currency: "jpy",
|
|
167
|
+
status: "paid",
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
await expect(s.run()).rejects.toThrow(
|
|
171
|
+
"Existing payout account does not match.",
|
|
172
|
+
);
|
|
173
|
+
expect(s.retrieve).not.toHaveBeenCalled();
|
|
174
|
+
expect(await s.row()).toMatchObject({ accountId: "acct_foreign" });
|
|
175
|
+
});
|
|
176
|
+
it("rejects changing a payout's durable account even if no financial row was saved", async () => {
|
|
177
|
+
const s = await setup();
|
|
178
|
+
s.retrieve.mockRejectedValueOnce(new Error("Unavailable"));
|
|
179
|
+
await expect(s.run()).rejects.toThrow("Unavailable");
|
|
180
|
+
s.retrieve.mockClear();
|
|
181
|
+
const sync = await operation();
|
|
182
|
+
await expect(
|
|
183
|
+
sync(s.provider, component, s.ctx, {
|
|
184
|
+
stripePayoutId: "po_one",
|
|
185
|
+
scope: { type: "connected", accountId: "acct_foreign" },
|
|
186
|
+
}),
|
|
187
|
+
).rejects.toThrow("Payout synchronization account does not match.");
|
|
188
|
+
expect(s.retrieve).not.toHaveBeenCalled();
|
|
189
|
+
expect(await s.row()).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
it.each([{ amount: 501 }, { currency: "kwd" }])(
|
|
192
|
+
"rejects changed original money %j without changing summaries",
|
|
193
|
+
async (patch) => {
|
|
194
|
+
const s = await setup();
|
|
195
|
+
await s.run();
|
|
196
|
+
s.set(patch);
|
|
197
|
+
await expect(s.run()).rejects.toThrow(
|
|
198
|
+
"Existing payout original money does not match.",
|
|
199
|
+
);
|
|
200
|
+
expect(await s.row()).toMatchObject({ amount: 500, currency: "jpy" });
|
|
201
|
+
expect(
|
|
202
|
+
await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
|
|
203
|
+
).toMatchObject({ paidOut: 500, count: 1 });
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
it.each([0, -1, 0.5, Number.MAX_SAFE_INTEGER + 1, NaN])(
|
|
207
|
+
"rejects invalid provider amount %s without a financial record",
|
|
208
|
+
async (amount) => {
|
|
209
|
+
const s = await setup();
|
|
210
|
+
s.set({ amount });
|
|
211
|
+
await expect(s.run()).rejects.toThrow(
|
|
212
|
+
"Invalid payout synchronization money or method.",
|
|
213
|
+
);
|
|
214
|
+
expect(await s.row()).toBeNull();
|
|
215
|
+
expect(
|
|
216
|
+
await s.t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
|
|
217
|
+
).toMatchObject({ status: "unknown" });
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
it("rejects an unavailable provider response and preserves retryability", async () => {
|
|
221
|
+
const s = await setup();
|
|
222
|
+
s.retrieve.mockResolvedValueOnce(null as unknown as Stripe.Payout);
|
|
223
|
+
await expect(s.run()).rejects.toThrow(
|
|
224
|
+
"Provider payout response is unavailable.",
|
|
225
|
+
);
|
|
226
|
+
expect(await s.row()).toBeNull();
|
|
227
|
+
expect(await s.run()).toMatchObject({ status: "accepted", generation: 2 });
|
|
228
|
+
});
|
|
229
|
+
it("rejects a foreign provider payout ID", async () => {
|
|
230
|
+
const s = await setup();
|
|
231
|
+
s.set({ id: "po_foreign" });
|
|
232
|
+
await expect(s.run()).rejects.toThrow(
|
|
233
|
+
"Provider payout identity does not match.",
|
|
234
|
+
);
|
|
235
|
+
expect(await s.row()).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
it.each([NaN, -1, 8640000000001])(
|
|
238
|
+
"rejects invalid arrival seconds %s",
|
|
239
|
+
async (arrival_date) => {
|
|
240
|
+
const s = await setup();
|
|
241
|
+
s.set({ arrival_date });
|
|
242
|
+
await expect(s.run()).rejects.toThrow(
|
|
243
|
+
"Invalid provider payout arrival date.",
|
|
244
|
+
);
|
|
245
|
+
expect(await s.row()).toBeNull();
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
it.each(["jpy", "kwd"])(
|
|
249
|
+
"preserves exact safe original %s units at the maximum integer",
|
|
250
|
+
async (currency) => {
|
|
251
|
+
const s = await setup();
|
|
252
|
+
s.set({ currency, amount: Number.MAX_SAFE_INTEGER });
|
|
253
|
+
await s.run();
|
|
254
|
+
expect(await s.row()).toMatchObject({
|
|
255
|
+
currency,
|
|
256
|
+
amount: Number.MAX_SAFE_INTEGER,
|
|
257
|
+
});
|
|
258
|
+
expect(
|
|
259
|
+
await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
|
|
260
|
+
).toMatchObject({ currency, paidOut: Number.MAX_SAFE_INTEGER });
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
it("does not reinterpret a connected account named platform as platform authority", async () => {
|
|
264
|
+
const s = await setup({ type: "connected", accountId: "platform" });
|
|
265
|
+
await expect(s.run()).rejects.toThrow(
|
|
266
|
+
"Payout synchronization scope must be explicit.",
|
|
267
|
+
);
|
|
268
|
+
expect(s.retrieve).not.toHaveBeenCalled();
|
|
269
|
+
expect(await s.row()).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
it("deduplicates equivalent provider metadata order without a financial update", async () => {
|
|
272
|
+
const s = await setup();
|
|
273
|
+
s.set({ metadata: { b: "two", a: "one" } });
|
|
274
|
+
await s.run();
|
|
275
|
+
s.set({ metadata: { a: "one", b: "two" } });
|
|
276
|
+
expect(await s.run()).toMatchObject({ status: "accepted", changed: false });
|
|
277
|
+
});
|
|
278
|
+
it("publishes authoritative synchronization through the actual BetterStripe client", async () => {
|
|
279
|
+
const s = await setup();
|
|
280
|
+
const { BetterStripe } = await import("../index.js");
|
|
281
|
+
const spy = vi
|
|
282
|
+
.spyOn(
|
|
283
|
+
BetterStripe.prototype as unknown as { stripe: () => Stripe },
|
|
284
|
+
"stripe",
|
|
285
|
+
)
|
|
286
|
+
.mockReturnValue(s.provider);
|
|
287
|
+
try {
|
|
288
|
+
const client = new BetterStripe(component) as InstanceType<
|
|
289
|
+
typeof BetterStripe
|
|
290
|
+
> & {
|
|
291
|
+
syncPayoutFromStripe: (
|
|
292
|
+
ctx: RunCtx,
|
|
293
|
+
opts: { stripePayoutId: string; scope: Scope },
|
|
294
|
+
) => Promise<unknown>;
|
|
295
|
+
};
|
|
296
|
+
expect(
|
|
297
|
+
client.syncPayoutFromStripe,
|
|
298
|
+
"public authoritative payout method is missing",
|
|
299
|
+
).toBeTypeOf("function");
|
|
300
|
+
expect(
|
|
301
|
+
await client.syncPayoutFromStripe(s.ctx, {
|
|
302
|
+
stripePayoutId: "po_one",
|
|
303
|
+
scope: s.scope,
|
|
304
|
+
}),
|
|
305
|
+
).toMatchObject({ status: "accepted" });
|
|
306
|
+
expect(await s.row()).toMatchObject({
|
|
307
|
+
accountId: "acct_owner",
|
|
308
|
+
status: "paid",
|
|
309
|
+
});
|
|
310
|
+
} finally {
|
|
311
|
+
spy.mockRestore();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Infer } from "convex/values";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import { runMutationOrThrow } from "../helpers.js";
|
|
5
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
6
|
+
import { validateIdentity } from "../../component/payoutSync/helpers.js";
|
|
7
|
+
import type { result } from "../../component/payoutSync/validators.js";
|
|
8
|
+
import {
|
|
9
|
+
payoutAccount,
|
|
10
|
+
payoutSnapshot,
|
|
11
|
+
type PayoutSyncScope,
|
|
12
|
+
} from "./payoutSyncValidation.js";
|
|
13
|
+
export type PayoutSyncOptions = {
|
|
14
|
+
stripePayoutId: string;
|
|
15
|
+
scope: PayoutSyncScope;
|
|
16
|
+
};
|
|
17
|
+
export type PayoutSyncResult = Infer<typeof result>;
|
|
18
|
+
export type PreparedPayoutSync = Awaited<ReturnType<typeof preparePayoutSync>>;
|
|
19
|
+
async function markUnknown(
|
|
20
|
+
component: Component,
|
|
21
|
+
ctx: RunCtx,
|
|
22
|
+
observation: {
|
|
23
|
+
stripePayoutId: string;
|
|
24
|
+
accountId: string;
|
|
25
|
+
generation: number;
|
|
26
|
+
},
|
|
27
|
+
) {
|
|
28
|
+
await runMutationOrThrow(
|
|
29
|
+
ctx,
|
|
30
|
+
componentRef(component, "payoutSync/mutations/markUnknown"),
|
|
31
|
+
observation,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
/** Package-internal preparation for transactional host webhook dispatch. No financial record write. */
|
|
35
|
+
export async function preparePayoutSync(
|
|
36
|
+
stripe: Stripe,
|
|
37
|
+
component: Component,
|
|
38
|
+
ctx: RunCtx,
|
|
39
|
+
opts: PayoutSyncOptions,
|
|
40
|
+
) {
|
|
41
|
+
const identity = {
|
|
42
|
+
stripePayoutId: opts.stripePayoutId,
|
|
43
|
+
accountId: payoutAccount(opts.scope),
|
|
44
|
+
};
|
|
45
|
+
validateIdentity(identity);
|
|
46
|
+
if (!ctx.runMutation)
|
|
47
|
+
throw new Error("Payout synchronization requires runMutation.");
|
|
48
|
+
const generation = (await runMutationOrThrow(
|
|
49
|
+
ctx,
|
|
50
|
+
componentRef(component, "payoutSync/mutations/begin"),
|
|
51
|
+
identity,
|
|
52
|
+
)) as number;
|
|
53
|
+
try {
|
|
54
|
+
const payout = await stripe.payouts.retrieve(
|
|
55
|
+
identity.stripePayoutId,
|
|
56
|
+
{},
|
|
57
|
+
identity.accountId === "platform"
|
|
58
|
+
? {}
|
|
59
|
+
: { stripeAccount: identity.accountId },
|
|
60
|
+
);
|
|
61
|
+
return {
|
|
62
|
+
...identity,
|
|
63
|
+
generation,
|
|
64
|
+
payout: payoutSnapshot(payout, identity),
|
|
65
|
+
};
|
|
66
|
+
} catch (error) {
|
|
67
|
+
await markUnknown(component, ctx, { ...identity, generation });
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Host must authorize scope. This only reads Stripe and synchronizes the existing financial record. */
|
|
72
|
+
export async function syncPayoutFromStripe(
|
|
73
|
+
stripe: Stripe,
|
|
74
|
+
component: Component,
|
|
75
|
+
ctx: RunCtx,
|
|
76
|
+
opts: PayoutSyncOptions,
|
|
77
|
+
): Promise<PayoutSyncResult> {
|
|
78
|
+
const observation = await preparePayoutSync(stripe, component, ctx, opts);
|
|
79
|
+
try {
|
|
80
|
+
return (await runMutationOrThrow(
|
|
81
|
+
ctx,
|
|
82
|
+
componentRef(component, "payoutSync/mutations/finish"),
|
|
83
|
+
observation,
|
|
84
|
+
)) as PayoutSyncResult;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await markUnknown(component, ctx, {
|
|
87
|
+
stripePayoutId: observation.stripePayoutId,
|
|
88
|
+
accountId: observation.accountId,
|
|
89
|
+
generation: observation.generation,
|
|
90
|
+
});
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { expect, vi } from "vitest";
|
|
5
|
+
import type Stripe from "stripe";
|
|
6
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
7
|
+
import schema from "../../component/schema.js";
|
|
8
|
+
const modules = import.meta.glob("../../component/**/*.*s");
|
|
9
|
+
const symbol = Symbol.for("toReferencePath");
|
|
10
|
+
export const component = {
|
|
11
|
+
payoutPolicy: {
|
|
12
|
+
queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
|
|
13
|
+
},
|
|
14
|
+
} as unknown as Component;
|
|
15
|
+
export type Scope =
|
|
16
|
+
{ type: "connected"; accountId: string } | { type: "platform" };
|
|
17
|
+
type Sync = (
|
|
18
|
+
stripe: Stripe,
|
|
19
|
+
component: Component,
|
|
20
|
+
ctx: RunCtx,
|
|
21
|
+
opts: { stripePayoutId: string; scope: Scope },
|
|
22
|
+
) => Promise<unknown>;
|
|
23
|
+
export async function operation() {
|
|
24
|
+
const path = "./payoutSync.js";
|
|
25
|
+
const mod = (await import(path).catch(() => ({}))) as {
|
|
26
|
+
syncPayoutFromStripe?: Sync;
|
|
27
|
+
};
|
|
28
|
+
expect(
|
|
29
|
+
mod.syncPayoutFromStripe,
|
|
30
|
+
"authoritative payout synchronization is missing",
|
|
31
|
+
).toBeTypeOf("function");
|
|
32
|
+
return mod.syncPayoutFromStripe!;
|
|
33
|
+
}
|
|
34
|
+
export async function setup(
|
|
35
|
+
scope: Scope = { type: "connected", accountId: "acct_owner" },
|
|
36
|
+
) {
|
|
37
|
+
const t = convexTest(schema, modules);
|
|
38
|
+
const path = (ref: Record<symbol, string>) =>
|
|
39
|
+
ref[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
|
|
40
|
+
const mutate = (ref: Record<symbol, string>, args: never) =>
|
|
41
|
+
t.mutation(makeFunctionReference<"mutation">(path(ref)), args);
|
|
42
|
+
const ctx = {
|
|
43
|
+
runQuery: (ref: Record<symbol, string>, args: never) =>
|
|
44
|
+
t.query(makeFunctionReference<"query">(path(ref)), args),
|
|
45
|
+
runMutation: mutate,
|
|
46
|
+
} as unknown as RunCtx;
|
|
47
|
+
let payout = {
|
|
48
|
+
id: "po_one",
|
|
49
|
+
amount: 500,
|
|
50
|
+
currency: "jpy",
|
|
51
|
+
status: "paid",
|
|
52
|
+
arrival_date: 200,
|
|
53
|
+
created: 100,
|
|
54
|
+
automatic: true,
|
|
55
|
+
method: "standard",
|
|
56
|
+
type: "bank_account",
|
|
57
|
+
source_type: "card",
|
|
58
|
+
destination: "ba_one",
|
|
59
|
+
balance_transaction: "txn_pending",
|
|
60
|
+
failure_code: null,
|
|
61
|
+
failure_message: null,
|
|
62
|
+
metadata: { accountId: "acct_metadata_is_not_authority" },
|
|
63
|
+
} as unknown as Stripe.Payout;
|
|
64
|
+
const retrieve = vi.fn(async () => ({ ...payout }));
|
|
65
|
+
const provider = { payouts: { retrieve } } as unknown as Stripe;
|
|
66
|
+
const sync = await operation();
|
|
67
|
+
return {
|
|
68
|
+
t,
|
|
69
|
+
ctx,
|
|
70
|
+
mutate,
|
|
71
|
+
retrieve,
|
|
72
|
+
provider,
|
|
73
|
+
scope,
|
|
74
|
+
set: (patch: Partial<Stripe.Payout>) => {
|
|
75
|
+
payout = { ...payout, ...patch };
|
|
76
|
+
},
|
|
77
|
+
read: () => ({ ...payout }),
|
|
78
|
+
run: () =>
|
|
79
|
+
sync(provider, component, ctx, { stripePayoutId: "po_one", scope }),
|
|
80
|
+
row: () => t.run((ctx) => ctx.db.query("payouts").unique()),
|
|
81
|
+
};
|
|
82
|
+
}
|