@fantastic.dev/better-stripe 0.23.0 → 0.23.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.
@@ -0,0 +1,253 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { vi } from "vitest";
5
+ import type Stripe from "stripe";
6
+ import schema from "../../component/schema.js";
7
+ import type { Component, RunCtx } from "../helpers.js";
8
+ import { releaseRecipientPayout } from "./payoutExecution.js";
9
+ import { adoptRecipientPayouts } from "./payoutAdoption.js";
10
+ const modules = import.meta.glob("../../component/**/*.*s");
11
+ const symbol = Symbol.for("toReferencePath");
12
+ export const component = {
13
+ payoutPolicy: {
14
+ queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
15
+ },
16
+ } as unknown as Component;
17
+ export const scope = {
18
+ accountId: "acct_recipient",
19
+ ownerKey: "owner",
20
+ operationKey: "release-1",
21
+ currency: "usd",
22
+ independentLiabilities: 0,
23
+ };
24
+ export async function setup(amounts = [600, 400], currency = "usd") {
25
+ const t = convexTest(schema, modules);
26
+ await t.run(async (ctx) => {
27
+ await ctx.db.insert("payoutPolicies", {
28
+ accountId: scope.accountId,
29
+ ownerKey: scope.ownerKey,
30
+ mode: "stripe_managed",
31
+ status: "ready",
32
+ revision: 0,
33
+ operationKey: "enable",
34
+ country: "US",
35
+ thresholds: [],
36
+ holdDeadline: Date.now() + 86400000,
37
+ });
38
+ for (let i = 0; i < amounts.length; i++)
39
+ await ctx.db.insert("transfers", {
40
+ stripeTransferId: `tr_${i}`,
41
+ destinationAccountId: scope.accountId,
42
+ sourceChargeId: `ch_${i}`,
43
+ amount: amounts[i],
44
+ currency,
45
+ status: "paid",
46
+ role: i % 2 ? "affiliate" : "store",
47
+ });
48
+ });
49
+ const path = (ref: Record<symbol, string>) =>
50
+ ref[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
51
+ const ctx = {
52
+ runQuery: (ref: Record<symbol, string>, args: never) =>
53
+ t.query(makeFunctionReference<"query">(path(ref)), args),
54
+ runMutation: (ref: Record<symbol, string>, args: never) =>
55
+ t.mutation(makeFunctionReference<"mutation">(path(ref)), args),
56
+ } as unknown as RunCtx;
57
+ let transactions = amounts.map((amount, i) => ({
58
+ id: `txn_${i}`,
59
+ source: `py_${i}`,
60
+ amount,
61
+ fee: 0,
62
+ net: amount,
63
+ currency,
64
+ created: 100,
65
+ available_on: 200,
66
+ status: "available",
67
+ type: "payment",
68
+ exchange_rate: null,
69
+ }));
70
+ let payout: Record<string, unknown> | undefined;
71
+ const history: Record<string, unknown>[] = [];
72
+ const provider = {
73
+ accounts: {
74
+ listExternalAccounts: vi.fn(async () => ({
75
+ data: [
76
+ {
77
+ id: "ba_one",
78
+ object: "bank_account",
79
+ currency,
80
+ default_for_currency: true,
81
+ },
82
+ ],
83
+ has_more: false,
84
+ })),
85
+ },
86
+ transfers: {
87
+ retrieve: vi.fn(async (id: string) => {
88
+ const i = Number(id.slice(3));
89
+ return {
90
+ id,
91
+ destination: scope.accountId,
92
+ destination_payment: `py_${i}`,
93
+ source_transaction: `ch_${i}`,
94
+ amount: amounts[i],
95
+ currency,
96
+ amount_reversed: 0,
97
+ };
98
+ }),
99
+ listReversals: vi.fn(async () => ({ data: [], has_more: false })),
100
+ },
101
+ charges: {
102
+ retrieve: vi.fn(async (id: string) => {
103
+ const i = Number(id.slice(3));
104
+ return {
105
+ id,
106
+ source_transfer: `tr_${i}`,
107
+ amount: amounts[i],
108
+ currency,
109
+ paid: true,
110
+ status: "succeeded",
111
+ balance_transaction: `txn_${i}`,
112
+ };
113
+ }),
114
+ },
115
+ balanceTransactions: {
116
+ list: vi.fn(async (params: { source?: string; limit?: number }) => ({
117
+ data: params.source
118
+ ? transactions.filter((t) => t.source === params.source)
119
+ : transactions.slice(0, params.limit),
120
+ has_more: false,
121
+ })),
122
+ retrieve: vi.fn(async (id: string) =>
123
+ transactions.find((t) => t.id === id),
124
+ ),
125
+ },
126
+ balance: {
127
+ retrieve: vi.fn(async () => ({
128
+ available: [
129
+ {
130
+ currency,
131
+ amount: transactions
132
+ .filter((t) => t.status === "available")
133
+ .reduce((a, t) => a + t.net, 0),
134
+ source_types: {
135
+ card: transactions
136
+ .filter((t) => t.status === "available")
137
+ .reduce((a, t) => a + t.net, 0),
138
+ },
139
+ },
140
+ ],
141
+ pending: [],
142
+ })),
143
+ },
144
+ payouts: {
145
+ list: vi.fn(async () => ({
146
+ data: [...history].reverse(),
147
+ has_more: false,
148
+ })),
149
+ retrieve: vi.fn(async (id?: string) =>
150
+ id ? history.find((p) => p.id === id) : payout,
151
+ ),
152
+ create: vi.fn(
153
+ async (params: {
154
+ amount: number;
155
+ currency: string;
156
+ metadata: object;
157
+ }) => {
158
+ const id = history.length ? `po_${history.length + 1}` : "po_one";
159
+ const debitId = history.length
160
+ ? `txn_debit_${history.length + 1}`
161
+ : "txn_debit";
162
+ payout = {
163
+ id,
164
+ ...params,
165
+ status: "paid",
166
+ automatic: false,
167
+ method: "standard",
168
+ type: "bank_account",
169
+ source_type: "card",
170
+ destination: "ba_one",
171
+ created: Math.floor(Date.now() / 1000),
172
+ arrival_date: Math.floor(Date.now() / 1000),
173
+ balance_transaction: debitId,
174
+ failure_balance_transaction: null,
175
+ reversed_by: null,
176
+ };
177
+ transactions = [
178
+ {
179
+ id: debitId,
180
+ source: id,
181
+ amount: -params.amount,
182
+ fee: 0,
183
+ net: -params.amount,
184
+ currency,
185
+ created: Math.floor(Date.now() / 1000),
186
+ available_on: Math.floor(Date.now() / 1000),
187
+ status: "available",
188
+ type: "payout",
189
+ exchange_rate: null,
190
+ },
191
+ ...transactions,
192
+ ];
193
+ history.push(payout);
194
+ return payout;
195
+ },
196
+ ),
197
+ },
198
+ };
199
+ const verify = vi.fn(async () => ({
200
+ status: "verified" as const,
201
+ policy: (await t.run((ctx) => ctx.db.query("payoutPolicies").first()))!,
202
+ }));
203
+ const run = (extra = {}) =>
204
+ releaseRecipientPayout(
205
+ provider as unknown as Stripe,
206
+ component,
207
+ ctx,
208
+ { ...scope, currency, ...extra },
209
+ verify,
210
+ );
211
+ return {
212
+ t,
213
+ ctx,
214
+ provider,
215
+ verify,
216
+ run,
217
+ setTransactions: (rows: typeof transactions) => {
218
+ transactions = rows;
219
+ },
220
+ transactions: () => transactions,
221
+ };
222
+ }
223
+ export async function historical(currency = "usd") {
224
+ const amounts = [600, 400];
225
+ const s = await setup(amounts, currency);
226
+ await s.provider.payouts.create({
227
+ amount: 1000,
228
+ currency,
229
+ metadata: {},
230
+ });
231
+ s.provider.payouts.create.mockClear();
232
+ const opts = {
233
+ accountId: scope.accountId,
234
+ ownerKey: scope.ownerKey,
235
+ currency,
236
+ expectedRevision: 0,
237
+ payouts: [
238
+ {
239
+ stripePayoutId: "po_one",
240
+ allocations: [
241
+ { stripeTransferId: "tr_0", amount: 600 },
242
+ { stripeTransferId: "tr_1", amount: 400 },
243
+ ],
244
+ },
245
+ ],
246
+ };
247
+ const adopt = (extra = {}) =>
248
+ adoptRecipientPayouts(s.provider as unknown as Stripe, component, s.ctx, {
249
+ ...opts,
250
+ ...extra,
251
+ });
252
+ return { ...s, adopt, opts, amounts };
253
+ }
@@ -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
+ );