@fantastic.dev/better-stripe 0.17.1 → 0.19.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/README.md +10 -1
- package/dist/client/connect/payouts.d.ts +3 -2
- package/dist/client/connect/payouts.d.ts.map +1 -1
- package/dist/client/connect/payouts.js.map +1 -1
- package/dist/client/connect/transferReceipt.d.ts +48 -0
- package/dist/client/connect/transferReceipt.d.ts.map +1 -0
- package/dist/client/connect/transferReceipt.js +296 -0
- package/dist/client/connect/transferReceipt.js.map +1 -0
- package/dist/client/index.d.ts +4 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +84 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -0
- package/dist/client/payoutPolicy.js +266 -0
- package/dist/client/payoutPolicy.js.map +1 -0
- package/dist/component/_generated/component.d.ts +273 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +150 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/helpers.d.ts +51 -0
- package/dist/component/payoutPolicy/helpers.d.ts.map +1 -0
- package/dist/component/payoutPolicy/helpers.js +68 -0
- package/dist/component/payoutPolicy/helpers.js.map +1 -0
- package/dist/component/payoutPolicy/mutations.d.ts +172 -0
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -0
- package/dist/component/payoutPolicy/mutations.js +191 -0
- package/dist/component/payoutPolicy/mutations.js.map +1 -0
- package/dist/component/payoutPolicy/queries.d.ts +97 -0
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -0
- package/dist/component/payoutPolicy/queries.js +51 -0
- package/dist/component/payoutPolicy/queries.js.map +1 -0
- package/dist/component/payoutPolicy/schema.d.ts +396 -0
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -0
- package/dist/component/payoutPolicy/schema.js +54 -0
- package/dist/component/payoutPolicy/schema.js.map +1 -0
- package/dist/component/schema.d.ts +131 -0
- package/dist/component/schema.js +3 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/settlement.md +112 -0
- package/package.json +1 -1
- package/src/client/connect/payouts.ts +10 -8
- package/src/client/connect/transferReceipt.pagination.test.ts +209 -0
- package/src/client/connect/transferReceipt.test.ts +520 -0
- package/src/client/connect/transferReceipt.ts +422 -0
- package/src/client/index.ts +7 -11
- package/src/client/payoutPolicy.test.ts +320 -0
- package/src/client/payoutPolicy.ts +422 -0
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +280 -0
- package/src/component/payout-policy.test.ts +248 -0
- package/src/component/payoutPolicy/helpers.ts +88 -0
- package/src/component/payoutPolicy/mutations.ts +225 -0
- package/src/component/payoutPolicy/queries.ts +56 -0
- package/src/component/payoutPolicy/schema.ts +67 -0
- package/src/component/schema.ts +3 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference, type FunctionReference } from "convex/server";
|
|
4
|
+
import { expect, it, 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
|
+
const ref = (path: string) => ({ [symbol]: `betterStripe/${path}` });
|
|
11
|
+
const component = {
|
|
12
|
+
payoutPolicy: { queries: { get: ref("payoutPolicy/queries/get") } },
|
|
13
|
+
} as unknown as Component;
|
|
14
|
+
const scope = { accountId: "acct_recipient", ownerKey: "user_1" };
|
|
15
|
+
const options = {
|
|
16
|
+
...scope,
|
|
17
|
+
operationKey: "enable",
|
|
18
|
+
expectedRevision: -1,
|
|
19
|
+
mode: "custom" as const,
|
|
20
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
21
|
+
consent: true as const,
|
|
22
|
+
};
|
|
23
|
+
async function feature() {
|
|
24
|
+
const mod = (await import("./payoutPolicy.js").catch(() => ({}))) as {
|
|
25
|
+
PayoutPolicyClient?: typeof import("./payoutPolicy.js").PayoutPolicyClient;
|
|
26
|
+
};
|
|
27
|
+
expect(
|
|
28
|
+
mod.PayoutPolicyClient,
|
|
29
|
+
"public payout-policy client is missing",
|
|
30
|
+
).toBeTypeOf("function");
|
|
31
|
+
return mod.PayoutPolicyClient!;
|
|
32
|
+
}
|
|
33
|
+
async function setup() {
|
|
34
|
+
const t = convexTest(schema, modules);
|
|
35
|
+
const path = (r: Record<symbol, string>) =>
|
|
36
|
+
r[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
|
|
37
|
+
const ctx = {
|
|
38
|
+
runQuery: async (
|
|
39
|
+
r: Record<symbol, string>,
|
|
40
|
+
args: Record<string, unknown>,
|
|
41
|
+
) => t.query(makeFunctionReference<"query">(path(r)), args),
|
|
42
|
+
runMutation: async (
|
|
43
|
+
r: Record<symbol, string>,
|
|
44
|
+
args: Record<string, unknown>,
|
|
45
|
+
) => t.mutation(makeFunctionReference<"mutation">(path(r)), args),
|
|
46
|
+
} as unknown as RunCtx;
|
|
47
|
+
let schedule: Record<string, unknown> = {
|
|
48
|
+
interval: "weekly",
|
|
49
|
+
weekly_payout_days: ["monday"],
|
|
50
|
+
};
|
|
51
|
+
const provider = {
|
|
52
|
+
v2: {
|
|
53
|
+
core: {
|
|
54
|
+
accounts: {
|
|
55
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
56
|
+
id: scope.accountId,
|
|
57
|
+
dashboard: "none",
|
|
58
|
+
identity: { country: "US" },
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
balance: {
|
|
64
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
65
|
+
available: [{ currency: "usd", amount: 0 }],
|
|
66
|
+
pending: [],
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
payouts: { list: vi.fn().mockResolvedValue({ data: [], has_more: false }) },
|
|
70
|
+
balanceSettings: {
|
|
71
|
+
retrieve: vi.fn(async () => ({
|
|
72
|
+
payments: { payouts: { status: "enabled", schedule } },
|
|
73
|
+
})),
|
|
74
|
+
update: vi.fn(
|
|
75
|
+
async (
|
|
76
|
+
args: {
|
|
77
|
+
payments: { payouts: { schedule: Record<string, unknown> } };
|
|
78
|
+
},
|
|
79
|
+
_request?: unknown,
|
|
80
|
+
) => {
|
|
81
|
+
schedule = args.payments.payouts.schedule;
|
|
82
|
+
return { payments: { payouts: { status: "enabled", schedule } } };
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const Client = await feature();
|
|
88
|
+
const client = new Client(component, () => provider as unknown as Stripe);
|
|
89
|
+
return {
|
|
90
|
+
t,
|
|
91
|
+
ctx,
|
|
92
|
+
provider,
|
|
93
|
+
client,
|
|
94
|
+
setSchedule: (value: Record<string, unknown>) => {
|
|
95
|
+
schedule = value;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
it("persists original provider settings, verifies manual schedule and restores opt-out", async () => {
|
|
100
|
+
const { client, ctx, provider } = await setup();
|
|
101
|
+
const prepared = await client.prepare(ctx, options);
|
|
102
|
+
expect(prepared.status).toBe("prepared");
|
|
103
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
104
|
+
expect((await client.get(ctx, scope))!.status).toBe("changing");
|
|
105
|
+
const applied = await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
106
|
+
expect(applied.status).toBe("ready");
|
|
107
|
+
expect(await client.verify(ctx, scope)).toMatchObject({
|
|
108
|
+
status: "verified",
|
|
109
|
+
providerMinimum: null,
|
|
110
|
+
});
|
|
111
|
+
await client.prepare(ctx, {
|
|
112
|
+
...options,
|
|
113
|
+
operationKey: "disable",
|
|
114
|
+
expectedRevision: 0,
|
|
115
|
+
mode: "stripe_managed",
|
|
116
|
+
thresholds: [],
|
|
117
|
+
consent: undefined,
|
|
118
|
+
});
|
|
119
|
+
await client.apply(ctx, { ...scope, operationKey: "disable" });
|
|
120
|
+
expect(provider.balanceSettings.update.mock.calls[1][0]).toMatchObject({
|
|
121
|
+
payments: {
|
|
122
|
+
payouts: {
|
|
123
|
+
schedule: { interval: "weekly", weekly_payout_days: ["monday"] },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
expect((await client.get(ctx, scope))!.mode).toBe("stripe_managed");
|
|
128
|
+
});
|
|
129
|
+
it("recovers provider success after a failed persistence write without changing the request", async () => {
|
|
130
|
+
const { client, ctx, provider } = await setup();
|
|
131
|
+
await client.prepare(ctx, options);
|
|
132
|
+
const original = ctx.runMutation!;
|
|
133
|
+
let fail = true;
|
|
134
|
+
ctx.runMutation = (async (
|
|
135
|
+
ref: FunctionReference<"mutation", "public" | "internal">,
|
|
136
|
+
args: Record<string, unknown>,
|
|
137
|
+
) => {
|
|
138
|
+
if (
|
|
139
|
+
fail &&
|
|
140
|
+
(ref as unknown as Record<symbol, string>)[symbol].endsWith("/confirm")
|
|
141
|
+
) {
|
|
142
|
+
fail = false;
|
|
143
|
+
throw new Error("write failed");
|
|
144
|
+
}
|
|
145
|
+
return original(ref, args);
|
|
146
|
+
}) as RunCtx["runMutation"];
|
|
147
|
+
await expect(
|
|
148
|
+
client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
149
|
+
).rejects.toThrow("write failed");
|
|
150
|
+
expect((await client.get(ctx, scope))!.status).toBe("changing");
|
|
151
|
+
expect(
|
|
152
|
+
(await client.apply(ctx, { ...scope, operationKey: "enable" })).status,
|
|
153
|
+
).toBe("ready");
|
|
154
|
+
expect(provider.balanceSettings.update).toHaveBeenCalledTimes(1);
|
|
155
|
+
expect(provider.balanceSettings.update.mock.calls[0][1]).toMatchObject({
|
|
156
|
+
stripeAccount: scope.accountId,
|
|
157
|
+
idempotencyKey: expect.any(String),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
it("does not adopt arbitrary existing balances or in-flight payouts as earnings", async () => {
|
|
161
|
+
const { client, ctx, provider } = await setup();
|
|
162
|
+
provider.balance.retrieve.mockResolvedValueOnce({
|
|
163
|
+
available: [{ currency: "usd", amount: 100 }],
|
|
164
|
+
pending: [],
|
|
165
|
+
});
|
|
166
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
167
|
+
status: "migration_required",
|
|
168
|
+
reason: "existing_balance",
|
|
169
|
+
});
|
|
170
|
+
expect(await client.get(ctx, scope))!.toBeNull();
|
|
171
|
+
provider.payouts.list.mockResolvedValueOnce({
|
|
172
|
+
data: [{ id: "po_old" }],
|
|
173
|
+
has_more: false,
|
|
174
|
+
} as never);
|
|
175
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
176
|
+
status: "migration_required",
|
|
177
|
+
reason: "in_flight_payouts",
|
|
178
|
+
});
|
|
179
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
it("returns a limitation for Dashboard withdrawal bypass and never changes account liability", async () => {
|
|
182
|
+
const { client, ctx, provider } = await setup();
|
|
183
|
+
provider.v2.core.accounts.retrieve.mockResolvedValueOnce({
|
|
184
|
+
id: scope.accountId,
|
|
185
|
+
dashboard: "full",
|
|
186
|
+
identity: { country: "US" },
|
|
187
|
+
});
|
|
188
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
189
|
+
status: "unsupported",
|
|
190
|
+
reason: "payout_control_unverified",
|
|
191
|
+
});
|
|
192
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
it("fails closed on drift and leaves an unknown provider write pending for retry", async () => {
|
|
195
|
+
const { client, ctx, provider, setSchedule } = await setup();
|
|
196
|
+
await client.prepare(ctx, options);
|
|
197
|
+
provider.balanceSettings.update.mockRejectedValueOnce(
|
|
198
|
+
new Error("network unavailable"),
|
|
199
|
+
);
|
|
200
|
+
await expect(
|
|
201
|
+
client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
202
|
+
).rejects.toThrow("network unavailable");
|
|
203
|
+
expect((await client.get(ctx, scope))!.status).toBe("changing");
|
|
204
|
+
setSchedule({ interval: "daily" });
|
|
205
|
+
expect(
|
|
206
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
207
|
+
).toMatchObject({ status: "schedule_drift" });
|
|
208
|
+
});
|
|
209
|
+
it("requires consent and scope/version validation before provider access", async () => {
|
|
210
|
+
const { client, ctx, provider } = await setup();
|
|
211
|
+
await expect(
|
|
212
|
+
client.prepare(ctx, { ...options, consent: undefined }),
|
|
213
|
+
).rejects.toThrow(/consent/);
|
|
214
|
+
await expect(
|
|
215
|
+
client.prepare(ctx, { ...options, expectedRevision: 0.5 }),
|
|
216
|
+
).rejects.toThrow(/revision/);
|
|
217
|
+
expect(provider.v2.core.accounts.retrieve).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
it("restores original settings at the holding deadline, without extending it", async () => {
|
|
220
|
+
const { client, ctx, t, provider } = await setup();
|
|
221
|
+
await client.prepare(ctx, options);
|
|
222
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
223
|
+
expect((await client.expire(ctx, scope)).status).toBe("not_due");
|
|
224
|
+
await t.run(async (c) => {
|
|
225
|
+
const p = await c.db.query("payoutPolicies").first();
|
|
226
|
+
await c.db.patch(p!._id, { holdDeadline: Date.now() - 1 });
|
|
227
|
+
});
|
|
228
|
+
expect((await client.expire(ctx, scope)).status).toBe("ready");
|
|
229
|
+
expect((await client.get(ctx, scope))!.mode).toBe("stripe_managed");
|
|
230
|
+
expect(provider.balanceSettings.update).toHaveBeenCalledTimes(2);
|
|
231
|
+
});
|
|
232
|
+
it("re-checks live control on every payout-policy verification", async () => {
|
|
233
|
+
const { client, ctx, setSchedule } = await setup();
|
|
234
|
+
await client.prepare(ctx, options);
|
|
235
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
236
|
+
setSchedule({ interval: "daily" });
|
|
237
|
+
expect(await client.verify(ctx, scope)).toMatchObject({
|
|
238
|
+
status: "schedule_drift",
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
it("reports a staged initial enable as pending rather than unconfigured", async () => {
|
|
242
|
+
const { client, ctx } = await setup();
|
|
243
|
+
await client.prepare(ctx, options);
|
|
244
|
+
expect(await client.verify(ctx, scope)).toMatchObject({
|
|
245
|
+
status: "pending_operation",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
it("restores opt-out even after exclusive Dashboard control is lost", async () => {
|
|
249
|
+
const { client, ctx, provider } = await setup();
|
|
250
|
+
await client.prepare(ctx, options);
|
|
251
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
252
|
+
provider.v2.core.accounts.retrieve.mockResolvedValue({
|
|
253
|
+
id: scope.accountId,
|
|
254
|
+
dashboard: "full",
|
|
255
|
+
identity: { country: "US" },
|
|
256
|
+
});
|
|
257
|
+
expect(
|
|
258
|
+
(
|
|
259
|
+
await client.prepare(ctx, {
|
|
260
|
+
...options,
|
|
261
|
+
operationKey: "off-lost-control",
|
|
262
|
+
expectedRevision: 0,
|
|
263
|
+
mode: "stripe_managed",
|
|
264
|
+
thresholds: [],
|
|
265
|
+
})
|
|
266
|
+
).status,
|
|
267
|
+
).toBe("prepared");
|
|
268
|
+
expect(
|
|
269
|
+
(await client.apply(ctx, { ...scope, operationKey: "off-lost-control" }))
|
|
270
|
+
.status,
|
|
271
|
+
).toBe("ready");
|
|
272
|
+
});
|
|
273
|
+
it("cancels an expired, unattempted enable without switching a live schedule to manual", async () => {
|
|
274
|
+
const { client, ctx, t, provider } = await setup();
|
|
275
|
+
await client.prepare(ctx, options);
|
|
276
|
+
await t.run(async (c) => {
|
|
277
|
+
const p = await c.db.query("payoutPolicies").first();
|
|
278
|
+
await c.db.patch(p!._id, { holdDeadline: Date.now() - 1 });
|
|
279
|
+
});
|
|
280
|
+
await client.expire(ctx, scope);
|
|
281
|
+
expect((await client.get(ctx, scope))!.mode).toBe("stripe_managed");
|
|
282
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
283
|
+
});
|
|
284
|
+
it("reports explicit Stripe permission denials and keeps uncertain operations held", async () => {
|
|
285
|
+
const { client, ctx, provider } = await setup();
|
|
286
|
+
const denied = () =>
|
|
287
|
+
Object.assign(new Error("permission denied"), {
|
|
288
|
+
type: "StripePermissionError",
|
|
289
|
+
});
|
|
290
|
+
provider.v2.core.accounts.retrieve.mockRejectedValueOnce(denied());
|
|
291
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
292
|
+
status: "unsupported",
|
|
293
|
+
reason: "provider_permission_denied",
|
|
294
|
+
});
|
|
295
|
+
await client.prepare(ctx, options);
|
|
296
|
+
provider.balanceSettings.update.mockRejectedValueOnce(denied());
|
|
297
|
+
expect(
|
|
298
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
299
|
+
).toMatchObject({
|
|
300
|
+
status: "unsupported",
|
|
301
|
+
reason: "provider_permission_denied",
|
|
302
|
+
});
|
|
303
|
+
expect((await client.get(ctx, scope))!.status).toBe("changing");
|
|
304
|
+
});
|
|
305
|
+
it("rechecks delayed activation and cancels safely when funds arrive after preparation", async () => {
|
|
306
|
+
const { client, ctx, provider } = await setup();
|
|
307
|
+
await client.prepare(ctx, options);
|
|
308
|
+
provider.balance.retrieve.mockResolvedValueOnce({
|
|
309
|
+
available: [{ currency: "usd", amount: 100 }],
|
|
310
|
+
pending: [],
|
|
311
|
+
});
|
|
312
|
+
expect(
|
|
313
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
314
|
+
).toMatchObject({ status: "migration_required", reason: "existing_balance" });
|
|
315
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
316
|
+
expect(await client.get(ctx, scope)).toMatchObject({
|
|
317
|
+
mode: "stripe_managed",
|
|
318
|
+
status: "ready",
|
|
319
|
+
});
|
|
320
|
+
});
|