@fantastic.dev/better-stripe 0.22.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.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/dist/client/connect/payoutActivationProof.d.ts +16 -0
- package/dist/client/connect/payoutActivationProof.d.ts.map +1 -0
- package/dist/client/connect/payoutActivationProof.js +72 -0
- package/dist/client/connect/payoutActivationProof.js.map +1 -0
- package/dist/client/connect/payoutAdoption.d.ts +35 -0
- package/dist/client/connect/payoutAdoption.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoption.js +231 -0
- package/dist/client/connect/payoutAdoption.js.map +1 -0
- package/dist/client/connect/payoutAdoptionAge.d.ts +19 -0
- package/dist/client/connect/payoutAdoptionAge.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionAge.js +56 -0
- package/dist/client/connect/payoutAdoptionAge.js.map +1 -0
- package/dist/client/connect/payoutAdoptionAllocation.d.ts +24 -0
- package/dist/client/connect/payoutAdoptionAllocation.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionAllocation.js +78 -0
- package/dist/client/connect/payoutAdoptionAllocation.js.map +1 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +2617 -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/payoutReconciliation.d.ts.map +1 -1
- package/dist/client/connect/payoutReconciliation.js +4 -2
- package/dist/client/connect/payoutReconciliation.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +7 -1
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +78 -7
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +196 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +38 -4
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/adoption.d.ts +9 -0
- package/dist/component/payoutPolicy/adoption.d.ts.map +1 -0
- package/dist/component/payoutPolicy/adoption.js +45 -0
- package/dist/component/payoutPolicy/adoption.js.map +1 -0
- package/dist/component/payoutPolicy/mutations.d.ts +96 -0
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.js +38 -5
- package/dist/component/payoutPolicy/mutations.js.map +1 -1
- package/dist/component/payoutPolicy/queries.d.ts +16 -0
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.d.ts +299 -2
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.js +7 -0
- package/dist/component/payoutPolicy/schema.js.map +1 -1
- package/dist/component/payoutRelease/adoption.d.ts +269 -0
- package/dist/component/payoutRelease/adoption.d.ts.map +1 -0
- package/dist/component/payoutRelease/adoption.js +197 -0
- package/dist/component/payoutRelease/adoption.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +3 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.js +20 -0
- package/dist/component/payoutRelease/helpers.js.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +81 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +6 -0
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/observation.d.ts +18 -0
- package/dist/component/payoutRelease/observation.d.ts.map +1 -0
- package/dist/component/payoutRelease/observation.js +11 -0
- package/dist/component/payoutRelease/observation.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +3 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +8 -19
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +3 -9
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +73 -2
- package/docs/settlement.md +127 -40
- package/package.json +1 -1
- package/src/client/connect/payoutActivationProof.ts +101 -0
- package/src/client/connect/payoutAdoption.test.ts +186 -0
- package/src/client/connect/payoutAdoption.ts +331 -0
- package/src/client/connect/payoutAdoptionAge.test.ts +89 -0
- package/src/client/connect/payoutAdoptionAge.ts +74 -0
- package/src/client/connect/payoutAdoptionAllocation.test.ts +132 -0
- package/src/client/connect/payoutAdoptionAllocation.ts +106 -0
- package/src/client/connect/payoutAdoptionTestHelpers.ts +253 -0
- package/src/client/connect/payoutReconciliation.test.ts +16 -0
- package/src/client/connect/payoutReconciliation.ts +6 -2
- package/src/client/connect/payoutReturnedAdoption.test.ts +157 -0
- package/src/client/payoutPolicy.test.ts +16 -0
- package/src/client/payoutPolicy.ts +107 -5
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +192 -1
- package/src/component/payout-adoption.test.ts +272 -0
- package/src/component/payout-policy-adoption.test.ts +131 -0
- package/src/component/payoutPolicy/adoption.ts +76 -0
- package/src/component/payoutPolicy/mutations.ts +46 -3
- package/src/component/payoutPolicy/schema.ts +9 -0
- package/src/component/payoutRelease/adoption.ts +262 -0
- package/src/component/payoutRelease/helpers.ts +21 -0
- package/src/component/payoutRelease/mutations.ts +10 -0
- package/src/component/payoutRelease/observation.ts +10 -0
- package/src/component/payoutRelease/schema.ts +3 -9
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { addSummaryAmounts as add } from "../../component/earnings/arithmetic.js";
|
|
2
|
+
export type HistoricalFundingSource = {
|
|
3
|
+
stripeTransferId: string;
|
|
4
|
+
currency: string;
|
|
5
|
+
transactions: {
|
|
6
|
+
net: number;
|
|
7
|
+
created: number;
|
|
8
|
+
availableOn: number;
|
|
9
|
+
status: string;
|
|
10
|
+
}[];
|
|
11
|
+
};
|
|
12
|
+
export type HistoricalFundingPayout = {
|
|
13
|
+
stripePayoutId: string;
|
|
14
|
+
currency: string;
|
|
15
|
+
amount: number;
|
|
16
|
+
created: number;
|
|
17
|
+
returnedAt?: number;
|
|
18
|
+
allocations: { stripeTransferId: string; amount: number }[];
|
|
19
|
+
};
|
|
20
|
+
function integer(n: number) {
|
|
21
|
+
if (!Number.isSafeInteger(n))
|
|
22
|
+
throw new Error("Invalid historical funding arithmetic.");
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
function timestamp(n: number) {
|
|
26
|
+
if (integer(n) < 0) throw new Error("Invalid historical funding timestamp.");
|
|
27
|
+
}
|
|
28
|
+
/** Validate explicit historical cash provenance, not today's still-unpaid entitlement. */
|
|
29
|
+
export function validateHistoricalFunding(
|
|
30
|
+
sources: HistoricalFundingSource[],
|
|
31
|
+
payouts: HistoricalFundingPayout[],
|
|
32
|
+
): void {
|
|
33
|
+
const byId = new Map<string, HistoricalFundingSource>();
|
|
34
|
+
for (const s of sources) {
|
|
35
|
+
if (byId.has(s.stripeTransferId))
|
|
36
|
+
throw new Error("Duplicate historical funding source.");
|
|
37
|
+
if (!/^tr_\w+$/.test(s.stripeTransferId) || !/^[a-z]{3}$/.test(s.currency))
|
|
38
|
+
throw new Error("Invalid historical funding source.");
|
|
39
|
+
for (const t of s.transactions) {
|
|
40
|
+
integer(t.net);
|
|
41
|
+
timestamp(t.created);
|
|
42
|
+
timestamp(t.availableOn);
|
|
43
|
+
if (!["available", "pending"].includes(t.status))
|
|
44
|
+
throw new Error("Invalid historical funding status.");
|
|
45
|
+
}
|
|
46
|
+
byId.set(s.stripeTransferId, s);
|
|
47
|
+
}
|
|
48
|
+
const ids = new Set<string>();
|
|
49
|
+
for (const p of payouts) {
|
|
50
|
+
if (ids.has(p.stripePayoutId))
|
|
51
|
+
throw new Error("Duplicate historical payout.");
|
|
52
|
+
ids.add(p.stripePayoutId);
|
|
53
|
+
if (
|
|
54
|
+
!/^po_\w+$/.test(p.stripePayoutId) ||
|
|
55
|
+
!/^[a-z]{3}$/.test(p.currency) ||
|
|
56
|
+
integer(p.amount) <= 0
|
|
57
|
+
)
|
|
58
|
+
throw new Error("Invalid historical payout principal.");
|
|
59
|
+
timestamp(p.created);
|
|
60
|
+
if (p.returnedAt !== undefined) {
|
|
61
|
+
timestamp(p.returnedAt);
|
|
62
|
+
if (p.returnedAt < p.created)
|
|
63
|
+
throw new Error("Historical payout return predates the payout.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const prior: HistoricalFundingPayout[] = [];
|
|
67
|
+
for (const p of [...payouts].sort(
|
|
68
|
+
(a, b) =>
|
|
69
|
+
a.created - b.created || a.stripePayoutId.localeCompare(b.stripePayoutId),
|
|
70
|
+
)) {
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
let principal = 0;
|
|
73
|
+
for (const a of p.allocations) {
|
|
74
|
+
if (seen.has(a.stripeTransferId))
|
|
75
|
+
throw new Error("Duplicate historical source allocation.");
|
|
76
|
+
seen.add(a.stripeTransferId);
|
|
77
|
+
if (integer(a.amount) <= 0)
|
|
78
|
+
throw new Error("Invalid historical principal allocation.");
|
|
79
|
+
principal = add(principal, a.amount);
|
|
80
|
+
const s = byId.get(a.stripeTransferId);
|
|
81
|
+
if (!s)
|
|
82
|
+
throw new Error("Historical payout funding source is unavailable.");
|
|
83
|
+
if (s.currency !== p.currency)
|
|
84
|
+
throw new Error("Historical payout currency allocation mismatch.");
|
|
85
|
+
let capacity = 0;
|
|
86
|
+
for (const t of s.transactions)
|
|
87
|
+
if (
|
|
88
|
+
t.status === "available" &&
|
|
89
|
+
Math.max(t.created, t.availableOn) <= p.created
|
|
90
|
+
)
|
|
91
|
+
capacity = add(capacity, t.net);
|
|
92
|
+
for (const old of prior)
|
|
93
|
+
if (old.returnedAt === undefined || old.returnedAt > p.created)
|
|
94
|
+
for (const allocation of old.allocations)
|
|
95
|
+
if (allocation.stripeTransferId === s.stripeTransferId)
|
|
96
|
+
capacity = add(capacity, -allocation.amount);
|
|
97
|
+
if (a.amount > capacity)
|
|
98
|
+
throw new Error(
|
|
99
|
+
"Historical payout exceeds verified mature source funding.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (principal !== p.amount)
|
|
103
|
+
throw new Error("Historical allocations do not equal payout principal.");
|
|
104
|
+
prior.push(p);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -396,3 +396,19 @@ it("rejects a payout sent to a different bank than the immutable claim", async (
|
|
|
396
396
|
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
397
397
|
).toMatchObject([{ paidAmount: 0, reservedAmount: 1000 }]);
|
|
398
398
|
});
|
|
399
|
+
it("reconciles an explicitly adopted automatic payout without a package claim marker", async () => {
|
|
400
|
+
const s = await setup();
|
|
401
|
+
await s.t.run(async (ctx) => {
|
|
402
|
+
const c = await ctx.db.query("payoutReleaseClaims").first();
|
|
403
|
+
await ctx.db.patch("payoutReleaseClaims", c!._id, {
|
|
404
|
+
historical: true,
|
|
405
|
+
stripePayoutId: "po_one",
|
|
406
|
+
destinationId: "ba_one",
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
s.setPayout({ automatic: true, metadata: {} as never });
|
|
410
|
+
expect(await s.reconcile()).toMatchObject({
|
|
411
|
+
status: "updated",
|
|
412
|
+
claim: { status: "paid", historical: true },
|
|
413
|
+
});
|
|
414
|
+
});
|
|
@@ -132,7 +132,7 @@ export async function reconcileRecipientPayout(
|
|
|
132
132
|
const receipt = await getRecipientPayoutReceipt(stripe, {
|
|
133
133
|
accountId: options.accountId,
|
|
134
134
|
stripePayoutId: payoutId,
|
|
135
|
-
operationKey: options.operationKey,
|
|
135
|
+
...(claim.historical ? {} : { operationKey: options.operationKey }),
|
|
136
136
|
});
|
|
137
137
|
if (receipt.status !== "ready")
|
|
138
138
|
return { status: "held", reason: receipt.reason };
|
|
@@ -151,7 +151,11 @@ export async function reconcileRecipientPayout(
|
|
|
151
151
|
);
|
|
152
152
|
// Current claims use the default card source balance. Other rails need a
|
|
153
153
|
// persisted allocation contract before a provider receipt can be adopted.
|
|
154
|
-
if (
|
|
154
|
+
if (
|
|
155
|
+
receipt.sourceType !== "card" ||
|
|
156
|
+
(claim.historical &&
|
|
157
|
+
(receipt.method !== "standard" || receipt.type !== "bank_account"))
|
|
158
|
+
)
|
|
155
159
|
return { status: "held", reason: "source_allocation_required" };
|
|
156
160
|
const fee = addSummaryAmounts(receipt.debit.fee, receipt.credit?.fee ?? 0);
|
|
157
161
|
if (fee < 0) return { status: "held", reason: "fee_allocation_required" };
|
|
@@ -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
|
+
);
|
|
@@ -318,3 +318,19 @@ it("rechecks delayed activation and cancels safely when funds arrive after prepa
|
|
|
318
318
|
status: "ready",
|
|
319
319
|
});
|
|
320
320
|
});
|
|
321
|
+
it("requires adoption for historical payouts even when today's balance is zero", async () => {
|
|
322
|
+
const { client, ctx, provider } = await setup();
|
|
323
|
+
provider.payouts.list.mockImplementation(
|
|
324
|
+
async (params?: { status?: string }) =>
|
|
325
|
+
({
|
|
326
|
+
data: params?.status ? [] : [{ id: "po_old" }],
|
|
327
|
+
has_more: false,
|
|
328
|
+
}) as never,
|
|
329
|
+
);
|
|
330
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
331
|
+
status: "migration_required",
|
|
332
|
+
reason: "existing_payout_history",
|
|
333
|
+
});
|
|
334
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
335
|
+
expect(await client.get(ctx, scope)).toBeNull();
|
|
336
|
+
});
|