@fantastic.dev/better-stripe 0.20.1 → 0.21.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 +6 -0
- package/README.md +11 -1
- package/dist/client/connect/payoutReceipt.d.ts +53 -0
- package/dist/client/connect/payoutReceipt.d.ts.map +1 -0
- package/dist/client/connect/payoutReceipt.js +185 -0
- package/dist/client/connect/payoutReceipt.js.map +1 -0
- package/dist/client/connect/payoutReconciliation.d.ts +38 -0
- package/dist/client/connect/payoutReconciliation.d.ts.map +1 -0
- package/dist/client/connect/payoutReconciliation.js +110 -0
- package/dist/client/connect/payoutReconciliation.js.map +1 -0
- package/dist/client/payoutPolicy.d.ts +6 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +8 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +39 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +50 -2
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutRelease/allocation.d.ts +1 -0
- package/dist/component/payoutRelease/allocation.d.ts.map +1 -1
- package/dist/component/payoutRelease/allocation.js +3 -0
- package/dist/component/payoutRelease/allocation.js.map +1 -1
- package/dist/component/payoutRelease/fees.d.ts +22 -0
- package/dist/component/payoutRelease/fees.d.ts.map +1 -0
- package/dist/component/payoutRelease/fees.js +73 -0
- package/dist/component/payoutRelease/fees.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +4 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +23 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +17 -0
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/queries.d.ts +16 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +10 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +38 -2
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +11 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +25 -1
- package/dist/component/schema.js +2 -1
- package/dist/component/schema.js.map +1 -1
- package/docs/settlement.md +74 -3
- package/package.json +1 -1
- package/src/client/connect/payoutAllocation.test.ts +36 -3
- package/src/client/connect/payoutReceipt.test.ts +312 -0
- package/src/client/connect/payoutReceipt.ts +287 -0
- package/src/client/connect/payoutReconciliation.test.ts +385 -0
- package/src/client/connect/payoutReconciliation.ts +174 -0
- package/src/client/payoutPolicy.ts +16 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +38 -0
- package/src/component/payout-fees.test.ts +224 -0
- package/src/component/payoutRelease/allocation.ts +6 -0
- package/src/component/payoutRelease/fees.ts +93 -0
- package/src/component/payoutRelease/mutations.ts +17 -0
- package/src/component/payoutRelease/queries.ts +11 -0
- package/src/component/payoutRelease/schema.ts +12 -0
- package/src/component/schema.ts +2 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
|
+
import type Stripe from "stripe";
|
|
3
|
+
import { getRecipientPayoutReceipt } from "./payoutReceipt.js";
|
|
4
|
+
const scope = { accountId: "acct_recipient", stripePayoutId: "po_one" };
|
|
5
|
+
const timestamp = Math.floor(Date.now() / 1000) - 10;
|
|
6
|
+
const payout = (extra = {}) => ({
|
|
7
|
+
id: "po_one",
|
|
8
|
+
object: "payout",
|
|
9
|
+
amount: 1000,
|
|
10
|
+
currency: "usd",
|
|
11
|
+
status: "paid",
|
|
12
|
+
automatic: false,
|
|
13
|
+
method: "standard",
|
|
14
|
+
type: "bank_account",
|
|
15
|
+
source_type: "card",
|
|
16
|
+
balance_transaction: "txn_debit",
|
|
17
|
+
failure_balance_transaction: null,
|
|
18
|
+
reversed_by: null,
|
|
19
|
+
destination: "ba_one",
|
|
20
|
+
created: timestamp,
|
|
21
|
+
arrival_date: timestamp + 86400,
|
|
22
|
+
metadata: {},
|
|
23
|
+
...extra,
|
|
24
|
+
});
|
|
25
|
+
const transaction = (extra = {}) => ({
|
|
26
|
+
id: "txn_debit",
|
|
27
|
+
source: "po_one",
|
|
28
|
+
amount: -1000,
|
|
29
|
+
fee: 0,
|
|
30
|
+
net: -1000,
|
|
31
|
+
currency: "usd",
|
|
32
|
+
status: "available",
|
|
33
|
+
available_on: timestamp,
|
|
34
|
+
created: timestamp,
|
|
35
|
+
type: "payout",
|
|
36
|
+
...extra,
|
|
37
|
+
});
|
|
38
|
+
function setup(extra = {}, debit = {}, credit = {}) {
|
|
39
|
+
const provider = {
|
|
40
|
+
payouts: { retrieve: vi.fn().mockResolvedValue(payout(extra)) },
|
|
41
|
+
balanceTransactions: {
|
|
42
|
+
retrieve: vi.fn(async (id: string) =>
|
|
43
|
+
id === "txn_debit"
|
|
44
|
+
? transaction(debit)
|
|
45
|
+
: transaction({
|
|
46
|
+
id: "txn_return",
|
|
47
|
+
amount: 1000,
|
|
48
|
+
net: 1000,
|
|
49
|
+
type: "payout_failure",
|
|
50
|
+
...credit,
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
provider,
|
|
57
|
+
read: (options = scope) =>
|
|
58
|
+
getRecipientPayoutReceipt(provider as unknown as Stripe, options),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
it("verifies a paid manual bank payout against its actual scoped debit", async () => {
|
|
62
|
+
const { provider, read } = setup();
|
|
63
|
+
expect(await read()).toMatchObject({
|
|
64
|
+
status: "ready",
|
|
65
|
+
...scope,
|
|
66
|
+
amount: 1000,
|
|
67
|
+
currency: "usd",
|
|
68
|
+
providerStatus: "paid",
|
|
69
|
+
automatic: false,
|
|
70
|
+
returnedPrincipal: 0,
|
|
71
|
+
returnedNet: 0,
|
|
72
|
+
debit: { id: "txn_debit", amount: -1000, net: -1000, fee: 0 },
|
|
73
|
+
});
|
|
74
|
+
expect(provider.payouts.retrieve).toHaveBeenNthCalledWith(
|
|
75
|
+
1,
|
|
76
|
+
"po_one",
|
|
77
|
+
{},
|
|
78
|
+
{ stripeAccount: scope.accountId },
|
|
79
|
+
);
|
|
80
|
+
expect(provider.balanceTransactions.retrieve).toHaveBeenCalledWith(
|
|
81
|
+
"txn_debit",
|
|
82
|
+
{},
|
|
83
|
+
{ stripeAccount: scope.accountId },
|
|
84
|
+
);
|
|
85
|
+
expect(provider.payouts.retrieve).toHaveBeenCalledTimes(2);
|
|
86
|
+
});
|
|
87
|
+
it("reads automatic history without pretending it identifies per-source allocations", async () => {
|
|
88
|
+
const { read } = setup({ automatic: true });
|
|
89
|
+
expect(await read()).toMatchObject({
|
|
90
|
+
status: "ready",
|
|
91
|
+
automatic: true,
|
|
92
|
+
returnedPrincipal: 0,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
it.each(["pending", "in_transit"])(
|
|
96
|
+
"preserves %s without saying the bank was paid",
|
|
97
|
+
async (status) => {
|
|
98
|
+
expect(await setup({ status }).read()).toMatchObject({
|
|
99
|
+
status: "ready",
|
|
100
|
+
providerStatus: status,
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
it.each(["failed", "canceled"])(
|
|
105
|
+
"verifies exact principal and signed fees returned after a %s payout",
|
|
106
|
+
async (status) => {
|
|
107
|
+
const result = await setup(
|
|
108
|
+
{ status, failure_balance_transaction: "txn_return" },
|
|
109
|
+
{ fee: 30, net: -1030 },
|
|
110
|
+
{
|
|
111
|
+
type: status === "failed" ? "payout_failure" : "payout_cancel",
|
|
112
|
+
fee: -10,
|
|
113
|
+
net: 1010,
|
|
114
|
+
},
|
|
115
|
+
).read();
|
|
116
|
+
expect(result).toMatchObject({
|
|
117
|
+
status: "ready",
|
|
118
|
+
providerStatus: status,
|
|
119
|
+
returnedPrincipal: 1000,
|
|
120
|
+
returnedNet: 1010,
|
|
121
|
+
debit: { fee: 30 },
|
|
122
|
+
credit: { id: "txn_return", fee: -10, net: 1010 },
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
it("does not release a failure without a return receipt", async () => {
|
|
127
|
+
expect(await setup({ status: "failed" }).read()).toEqual({
|
|
128
|
+
status: "not_ready",
|
|
129
|
+
...scope,
|
|
130
|
+
reason: "return_unavailable",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
it.each([{ status: "pending" }, { available_on: timestamp + 86400 }])(
|
|
134
|
+
"does not release an immature return (%j)",
|
|
135
|
+
async (credit) => {
|
|
136
|
+
const result = await setup(
|
|
137
|
+
{ status: "failed", failure_balance_transaction: "txn_return" },
|
|
138
|
+
{},
|
|
139
|
+
credit,
|
|
140
|
+
).read();
|
|
141
|
+
expect(result).toEqual({
|
|
142
|
+
status: "not_ready",
|
|
143
|
+
...scope,
|
|
144
|
+
reason: "return_pending",
|
|
145
|
+
});
|
|
146
|
+
expect(result).not.toHaveProperty("amount");
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
it("preserves a non-refundable payout fee separately from returned principal", async () => {
|
|
150
|
+
expect(
|
|
151
|
+
await setup(
|
|
152
|
+
{ status: "failed", failure_balance_transaction: "txn_return" },
|
|
153
|
+
{ fee: 30, net: -1030 },
|
|
154
|
+
).read(),
|
|
155
|
+
).toMatchObject({
|
|
156
|
+
status: "ready",
|
|
157
|
+
returnedPrincipal: 1000,
|
|
158
|
+
returnedNet: 1000,
|
|
159
|
+
debit: { fee: 30 },
|
|
160
|
+
credit: { fee: 0 },
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
it("fails closed when the provider changes the payout during receipt reads", async () => {
|
|
164
|
+
const { provider, read } = setup();
|
|
165
|
+
provider.payouts.retrieve
|
|
166
|
+
.mockResolvedValueOnce(payout())
|
|
167
|
+
.mockResolvedValueOnce(payout({ status: "failed" }));
|
|
168
|
+
expect(await read()).toEqual({
|
|
169
|
+
status: "not_ready",
|
|
170
|
+
...scope,
|
|
171
|
+
reason: "provider_changed",
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
it("does not allocate a reversed bank payout as a failed payout", async () => {
|
|
175
|
+
expect(await setup({ reversed_by: "po_reversal" }).read()).toEqual({
|
|
176
|
+
status: "not_ready",
|
|
177
|
+
...scope,
|
|
178
|
+
reason: "reversal_allocation_required",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
it("fails closed when settlement currencies differ", async () => {
|
|
182
|
+
expect(await setup({}, { currency: "eur" }).read()).toEqual({
|
|
183
|
+
status: "not_ready",
|
|
184
|
+
...scope,
|
|
185
|
+
reason: "currency_allocation_required",
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
it.each([
|
|
189
|
+
{ id: "txn_other" },
|
|
190
|
+
{ source: "po_other" },
|
|
191
|
+
{ amount: -999, net: -999 },
|
|
192
|
+
{ net: -1001 },
|
|
193
|
+
{ amount: -1000.1 },
|
|
194
|
+
{ fee: NaN },
|
|
195
|
+
{ type: "charge" },
|
|
196
|
+
])("rejects mismatched or unsafe payout debit %j", async (debit) => {
|
|
197
|
+
await expect(setup({}, debit).read()).rejects.toThrow(/receipt|integer/);
|
|
198
|
+
});
|
|
199
|
+
it.each([
|
|
200
|
+
{ source: "po_other" },
|
|
201
|
+
{ amount: 999, net: 999 },
|
|
202
|
+
{ amount: 1001, net: 1001 },
|
|
203
|
+
{ net: 999 },
|
|
204
|
+
{ type: "charge" },
|
|
205
|
+
{ id: "txn_debit" },
|
|
206
|
+
])("rejects mismatched payout return %j", async (credit) => {
|
|
207
|
+
await expect(
|
|
208
|
+
setup(
|
|
209
|
+
{ status: "failed", failure_balance_transaction: "txn_return" },
|
|
210
|
+
{},
|
|
211
|
+
credit,
|
|
212
|
+
).read(),
|
|
213
|
+
).rejects.toThrow(/receipt|integer/);
|
|
214
|
+
});
|
|
215
|
+
it("does not trust expanded receipt objects without retrieving their scoped records", async () => {
|
|
216
|
+
const { provider, read } = setup({
|
|
217
|
+
balance_transaction: { id: "txn_debit", amount: -5000 },
|
|
218
|
+
});
|
|
219
|
+
expect(await read()).toMatchObject({
|
|
220
|
+
status: "ready",
|
|
221
|
+
debit: { amount: -1000 },
|
|
222
|
+
});
|
|
223
|
+
expect(provider.balanceTransactions.retrieve).toHaveBeenCalledTimes(1);
|
|
224
|
+
});
|
|
225
|
+
it("binds recovery to the expected durable claim marker when requested", async () => {
|
|
226
|
+
const { provider } = setup({ metadata: { bsPayoutClaim: "claim-1" } });
|
|
227
|
+
const read = (operationKey: string) =>
|
|
228
|
+
getRecipientPayoutReceipt(provider as unknown as Stripe, {
|
|
229
|
+
...scope,
|
|
230
|
+
operationKey,
|
|
231
|
+
});
|
|
232
|
+
expect(await read("claim-1")).toMatchObject({
|
|
233
|
+
status: "ready",
|
|
234
|
+
operationKey: "claim-1",
|
|
235
|
+
});
|
|
236
|
+
await expect(read("other")).rejects.toThrow(/claim/);
|
|
237
|
+
});
|
|
238
|
+
it("detects claim marker drift on the final payout read", async () => {
|
|
239
|
+
const { provider } = setup();
|
|
240
|
+
provider.payouts.retrieve
|
|
241
|
+
.mockResolvedValueOnce(payout({ metadata: { bsPayoutClaim: "claim-1" } }))
|
|
242
|
+
.mockResolvedValueOnce(payout({ metadata: { bsPayoutClaim: "other" } }));
|
|
243
|
+
expect(
|
|
244
|
+
await getRecipientPayoutReceipt(provider as unknown as Stripe, {
|
|
245
|
+
...scope,
|
|
246
|
+
operationKey: "claim-1",
|
|
247
|
+
}),
|
|
248
|
+
).toMatchObject({ status: "not_ready", reason: "provider_changed" });
|
|
249
|
+
});
|
|
250
|
+
it("does not suppress provider errors as proof of a missing receipt", async () => {
|
|
251
|
+
const { provider, read } = setup();
|
|
252
|
+
provider.balanceTransactions.retrieve.mockRejectedValue(
|
|
253
|
+
new Error("provider unavailable"),
|
|
254
|
+
);
|
|
255
|
+
await expect(read()).rejects.toThrow("provider unavailable");
|
|
256
|
+
});
|
|
257
|
+
it("returns no totals when the original debit is not available", async () => {
|
|
258
|
+
expect(await setup({ balance_transaction: null }).read()).toEqual({
|
|
259
|
+
status: "not_ready",
|
|
260
|
+
...scope,
|
|
261
|
+
reason: "debit_unavailable",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
it("rejects foreign payout identities and malformed scope", async () => {
|
|
265
|
+
await expect(setup({ id: "po_other" }).read()).rejects.toThrow(/identity/);
|
|
266
|
+
await expect(
|
|
267
|
+
setup().read({ accountId: "acct_recipient\n", stripePayoutId: "po_one" }),
|
|
268
|
+
).rejects.toThrow(/identity/);
|
|
269
|
+
});
|
|
270
|
+
it("exposes the verifier through the public payout policy client", async () => {
|
|
271
|
+
const { PayoutPolicyClient } = await import("../payoutPolicy.js");
|
|
272
|
+
const { provider } = setup();
|
|
273
|
+
const client = new PayoutPolicyClient(
|
|
274
|
+
{} as never,
|
|
275
|
+
() => provider as unknown as Stripe,
|
|
276
|
+
);
|
|
277
|
+
expect(
|
|
278
|
+
client.payoutReceipt,
|
|
279
|
+
"public payout receipt reader missing",
|
|
280
|
+
).toBeTypeOf("function");
|
|
281
|
+
expect(await client.payoutReceipt(scope)).toMatchObject({
|
|
282
|
+
status: "ready",
|
|
283
|
+
stripePayoutId: "po_one",
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
it.each([{ automatic: true }, { method: "instant" }, { type: "card" }])(
|
|
287
|
+
"does not bind a non-standard payout to a durable bank claim (%j)",
|
|
288
|
+
async (extra) => {
|
|
289
|
+
const { provider } = setup({
|
|
290
|
+
...extra,
|
|
291
|
+
metadata: { bsPayoutClaim: "claim-1" },
|
|
292
|
+
});
|
|
293
|
+
await expect(
|
|
294
|
+
getRecipientPayoutReceipt(provider as unknown as Stripe, {
|
|
295
|
+
...scope,
|
|
296
|
+
operationKey: "claim-1",
|
|
297
|
+
}),
|
|
298
|
+
).rejects.toThrow(/claim/);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
it.each([{ status: "pending" }, { available_on: timestamp + 86400 }])(
|
|
302
|
+
"does not book a payout debit before its fees and net are available (%j)",
|
|
303
|
+
async (debit) => {
|
|
304
|
+
const receipt = await setup({}, debit).read();
|
|
305
|
+
expect(receipt).toEqual({
|
|
306
|
+
status: "not_ready",
|
|
307
|
+
...scope,
|
|
308
|
+
reason: "debit_pending",
|
|
309
|
+
});
|
|
310
|
+
expect(receipt).not.toHaveProperty("amount");
|
|
311
|
+
},
|
|
312
|
+
);
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { PayoutStatus } from "../../component/connect/validators.js";
|
|
3
|
+
export type RecipientPayoutReceiptOptions = {
|
|
4
|
+
accountId: string;
|
|
5
|
+
stripePayoutId: string;
|
|
6
|
+
/** Required when adopting a provider payout into a previously attempted claim. */
|
|
7
|
+
operationKey?: string;
|
|
8
|
+
};
|
|
9
|
+
export type PayoutReceiptTransaction = {
|
|
10
|
+
id: string;
|
|
11
|
+
amount: number;
|
|
12
|
+
fee: number;
|
|
13
|
+
net: number;
|
|
14
|
+
currency: string;
|
|
15
|
+
status: "available" | "pending";
|
|
16
|
+
created: number;
|
|
17
|
+
availableOn: number;
|
|
18
|
+
type: string;
|
|
19
|
+
};
|
|
20
|
+
export type RecipientPayoutReceipt =
|
|
21
|
+
| {
|
|
22
|
+
status: "not_ready";
|
|
23
|
+
accountId: string;
|
|
24
|
+
stripePayoutId: string;
|
|
25
|
+
reason:
|
|
26
|
+
| "debit_unavailable"
|
|
27
|
+
| "debit_pending"
|
|
28
|
+
| "return_unavailable"
|
|
29
|
+
| "return_pending"
|
|
30
|
+
| "provider_changed"
|
|
31
|
+
| "reversal_allocation_required"
|
|
32
|
+
| "currency_allocation_required";
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
status: "ready";
|
|
36
|
+
accountId: string;
|
|
37
|
+
stripePayoutId: string;
|
|
38
|
+
operationKey?: string;
|
|
39
|
+
amount: number;
|
|
40
|
+
currency: string;
|
|
41
|
+
providerStatus: PayoutStatus;
|
|
42
|
+
automatic: boolean;
|
|
43
|
+
method: string;
|
|
44
|
+
type: string;
|
|
45
|
+
sourceType: string;
|
|
46
|
+
destinationId: string;
|
|
47
|
+
created: number;
|
|
48
|
+
arrivalDate: number;
|
|
49
|
+
debit: PayoutReceiptTransaction;
|
|
50
|
+
credit?: PayoutReceiptTransaction;
|
|
51
|
+
returnedPrincipal: number;
|
|
52
|
+
returnedNet: number;
|
|
53
|
+
observedAt: number;
|
|
54
|
+
};
|
|
55
|
+
function id(value: unknown) {
|
|
56
|
+
return typeof value === "string"
|
|
57
|
+
? value
|
|
58
|
+
: value &&
|
|
59
|
+
typeof value === "object" &&
|
|
60
|
+
"id" in value &&
|
|
61
|
+
typeof value.id === "string"
|
|
62
|
+
? value.id
|
|
63
|
+
: undefined;
|
|
64
|
+
}
|
|
65
|
+
function safeInteger(value: number) {
|
|
66
|
+
if (!Number.isSafeInteger(value))
|
|
67
|
+
throw new Error(
|
|
68
|
+
"Payout receipt requires safe integer amounts and timestamps.",
|
|
69
|
+
);
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function timestamp(value: number) {
|
|
73
|
+
if (safeInteger(value) < 0)
|
|
74
|
+
throw new Error("Payout receipt timestamp is invalid.");
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function currency(value: string) {
|
|
78
|
+
if (value.length !== 3 || !/^[a-z]{3}$/.test(value))
|
|
79
|
+
throw new Error("Payout receipt currency is invalid.");
|
|
80
|
+
}
|
|
81
|
+
function fingerprint(p: Stripe.Payout) {
|
|
82
|
+
return JSON.stringify([
|
|
83
|
+
p.id,
|
|
84
|
+
p.amount,
|
|
85
|
+
p.currency,
|
|
86
|
+
p.status,
|
|
87
|
+
p.automatic,
|
|
88
|
+
p.method,
|
|
89
|
+
p.type,
|
|
90
|
+
p.source_type,
|
|
91
|
+
id(p.destination),
|
|
92
|
+
id(p.balance_transaction),
|
|
93
|
+
id(p.failure_balance_transaction),
|
|
94
|
+
id(p.reversed_by),
|
|
95
|
+
p.created,
|
|
96
|
+
p.arrival_date,
|
|
97
|
+
p.metadata?.bsPayoutClaim,
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
function transaction(
|
|
101
|
+
t: Stripe.BalanceTransaction,
|
|
102
|
+
expectedId: string,
|
|
103
|
+
payoutId: string,
|
|
104
|
+
amount: number,
|
|
105
|
+
types: string[],
|
|
106
|
+
): PayoutReceiptTransaction {
|
|
107
|
+
if (
|
|
108
|
+
t.id !== expectedId ||
|
|
109
|
+
id(t.source) !== payoutId ||
|
|
110
|
+
t.amount !== amount ||
|
|
111
|
+
!types.includes(t.type)
|
|
112
|
+
)
|
|
113
|
+
throw new Error(
|
|
114
|
+
"Payout transaction receipt identity or principal does not match.",
|
|
115
|
+
);
|
|
116
|
+
safeInteger(t.amount);
|
|
117
|
+
safeInteger(t.fee);
|
|
118
|
+
safeInteger(t.net);
|
|
119
|
+
if (safeInteger(t.amount - t.fee) !== t.net)
|
|
120
|
+
throw new Error("Payout transaction receipt net amount does not match.");
|
|
121
|
+
if (
|
|
122
|
+
!["available", "pending"].includes(t.status) ||
|
|
123
|
+
("balance_type" in t && t.balance_type !== "payments")
|
|
124
|
+
)
|
|
125
|
+
throw new Error("Payout transaction receipt balance is unsupported.");
|
|
126
|
+
currency(t.currency);
|
|
127
|
+
return {
|
|
128
|
+
id: t.id,
|
|
129
|
+
amount: t.amount,
|
|
130
|
+
fee: t.fee,
|
|
131
|
+
net: t.net,
|
|
132
|
+
currency: t.currency,
|
|
133
|
+
status: t.status as "available" | "pending",
|
|
134
|
+
created: timestamp(t.created),
|
|
135
|
+
availableOn: timestamp(t.available_on),
|
|
136
|
+
type: t.type,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Read-only provider receipts, not unpaid entitlement or authorization to release
|
|
141
|
+
* a reservation. Fees remain separate from principal and must be accounted for
|
|
142
|
+
* by the payout/recovery ledger. Manual payout receipts do not identify the
|
|
143
|
+
* original transfers that funded them. Host authorizes access to this account.
|
|
144
|
+
*/
|
|
145
|
+
export async function getRecipientPayoutReceipt(
|
|
146
|
+
stripe: Stripe,
|
|
147
|
+
options: RecipientPayoutReceiptOptions,
|
|
148
|
+
): Promise<RecipientPayoutReceipt> {
|
|
149
|
+
const scope = {
|
|
150
|
+
accountId: options.accountId,
|
|
151
|
+
stripePayoutId: options.stripePayoutId,
|
|
152
|
+
};
|
|
153
|
+
if (
|
|
154
|
+
!/^acct_\w+$/.test(scope.accountId) ||
|
|
155
|
+
scope.accountId.trim() !== scope.accountId ||
|
|
156
|
+
scope.accountId.length > 80 ||
|
|
157
|
+
!/^po_\w+$/.test(scope.stripePayoutId) ||
|
|
158
|
+
scope.stripePayoutId.trim() !== scope.stripePayoutId ||
|
|
159
|
+
scope.stripePayoutId.length > 100
|
|
160
|
+
)
|
|
161
|
+
throw new Error("Invalid recipient payout identity.");
|
|
162
|
+
if (
|
|
163
|
+
options.operationKey !== undefined &&
|
|
164
|
+
(!options.operationKey.trim() ||
|
|
165
|
+
options.operationKey.length > 180 ||
|
|
166
|
+
/[\r\n]/.test(options.operationKey))
|
|
167
|
+
)
|
|
168
|
+
throw new Error("Invalid payout claim identity.");
|
|
169
|
+
const request = { stripeAccount: scope.accountId };
|
|
170
|
+
const readPayout = () =>
|
|
171
|
+
stripe.payouts.retrieve(scope.stripePayoutId, {}, request);
|
|
172
|
+
const payout = await readPayout();
|
|
173
|
+
if (payout.id !== scope.stripePayoutId)
|
|
174
|
+
throw new Error("Provider payout identity does not match.");
|
|
175
|
+
if (safeInteger(payout.amount) <= 0)
|
|
176
|
+
throw new Error("Payout receipt principal is invalid.");
|
|
177
|
+
currency(payout.currency);
|
|
178
|
+
timestamp(payout.created);
|
|
179
|
+
timestamp(payout.arrival_date);
|
|
180
|
+
if (
|
|
181
|
+
!["pending", "in_transit", "paid", "failed", "canceled"].includes(
|
|
182
|
+
payout.status,
|
|
183
|
+
) ||
|
|
184
|
+
typeof payout.automatic !== "boolean" ||
|
|
185
|
+
!["standard", "instant"].includes(payout.method) ||
|
|
186
|
+
!["bank_account", "card"].includes(payout.type) ||
|
|
187
|
+
!["card", "bank_account", "fpx"].includes(payout.source_type) ||
|
|
188
|
+
!id(payout.destination)
|
|
189
|
+
)
|
|
190
|
+
throw new Error("Payout receipt state is unsupported.");
|
|
191
|
+
if (
|
|
192
|
+
options.operationKey !== undefined &&
|
|
193
|
+
payout.metadata?.bsPayoutClaim !== options.operationKey
|
|
194
|
+
)
|
|
195
|
+
throw new Error("Provider payout claim identity does not match.");
|
|
196
|
+
if (
|
|
197
|
+
options.operationKey !== undefined &&
|
|
198
|
+
(payout.automatic ||
|
|
199
|
+
payout.method !== "standard" ||
|
|
200
|
+
payout.type !== "bank_account")
|
|
201
|
+
)
|
|
202
|
+
throw new Error(
|
|
203
|
+
"Provider payout does not match a standard manual bank claim.",
|
|
204
|
+
);
|
|
205
|
+
if (payout.reversed_by != null)
|
|
206
|
+
return {
|
|
207
|
+
status: "not_ready",
|
|
208
|
+
...scope,
|
|
209
|
+
reason: "reversal_allocation_required",
|
|
210
|
+
};
|
|
211
|
+
const debitId = id(payout.balance_transaction);
|
|
212
|
+
if (!debitId)
|
|
213
|
+
return { status: "not_ready", ...scope, reason: "debit_unavailable" };
|
|
214
|
+
const debit = transaction(
|
|
215
|
+
await stripe.balanceTransactions.retrieve(debitId, {}, request),
|
|
216
|
+
debitId,
|
|
217
|
+
payout.id,
|
|
218
|
+
-payout.amount,
|
|
219
|
+
["payout"],
|
|
220
|
+
);
|
|
221
|
+
if (debit.currency !== payout.currency)
|
|
222
|
+
return {
|
|
223
|
+
status: "not_ready",
|
|
224
|
+
...scope,
|
|
225
|
+
reason: "currency_allocation_required",
|
|
226
|
+
};
|
|
227
|
+
if (
|
|
228
|
+
debit.status !== "available" ||
|
|
229
|
+
debit.availableOn > Math.floor(Date.now() / 1000)
|
|
230
|
+
)
|
|
231
|
+
return { status: "not_ready", ...scope, reason: "debit_pending" };
|
|
232
|
+
let credit: PayoutReceiptTransaction | undefined;
|
|
233
|
+
if (payout.status === "failed" || payout.status === "canceled") {
|
|
234
|
+
const creditId = id(payout.failure_balance_transaction);
|
|
235
|
+
if (!creditId)
|
|
236
|
+
return { status: "not_ready", ...scope, reason: "return_unavailable" };
|
|
237
|
+
if (creditId === debitId)
|
|
238
|
+
throw new Error(
|
|
239
|
+
"Payout return receipt cannot also be the original debit.",
|
|
240
|
+
);
|
|
241
|
+
credit = transaction(
|
|
242
|
+
await stripe.balanceTransactions.retrieve(creditId, {}, request),
|
|
243
|
+
creditId,
|
|
244
|
+
payout.id,
|
|
245
|
+
payout.amount,
|
|
246
|
+
["payout_failure", "payout_cancel"],
|
|
247
|
+
);
|
|
248
|
+
if (credit.currency !== payout.currency)
|
|
249
|
+
return {
|
|
250
|
+
status: "not_ready",
|
|
251
|
+
...scope,
|
|
252
|
+
reason: "currency_allocation_required",
|
|
253
|
+
};
|
|
254
|
+
if (
|
|
255
|
+
credit.status !== "available" ||
|
|
256
|
+
credit.availableOn > Math.floor(Date.now() / 1000)
|
|
257
|
+
)
|
|
258
|
+
return { status: "not_ready", ...scope, reason: "return_pending" };
|
|
259
|
+
} else if (payout.failure_balance_transaction != null) {
|
|
260
|
+
return { status: "not_ready", ...scope, reason: "provider_changed" };
|
|
261
|
+
}
|
|
262
|
+
const verified = await readPayout();
|
|
263
|
+
if (fingerprint(payout) !== fingerprint(verified))
|
|
264
|
+
return { status: "not_ready", ...scope, reason: "provider_changed" };
|
|
265
|
+
return {
|
|
266
|
+
status: "ready",
|
|
267
|
+
...scope,
|
|
268
|
+
...(payout.metadata?.bsPayoutClaim
|
|
269
|
+
? { operationKey: payout.metadata.bsPayoutClaim }
|
|
270
|
+
: {}),
|
|
271
|
+
amount: payout.amount,
|
|
272
|
+
currency: payout.currency,
|
|
273
|
+
providerStatus: payout.status as PayoutStatus,
|
|
274
|
+
automatic: payout.automatic,
|
|
275
|
+
method: payout.method,
|
|
276
|
+
type: payout.type,
|
|
277
|
+
sourceType: payout.source_type,
|
|
278
|
+
destinationId: id(payout.destination)!,
|
|
279
|
+
created: payout.created,
|
|
280
|
+
arrivalDate: payout.arrival_date,
|
|
281
|
+
debit,
|
|
282
|
+
...(credit ? { credit } : {}),
|
|
283
|
+
returnedPrincipal: credit?.amount ?? 0,
|
|
284
|
+
returnedNet: credit?.net ?? 0,
|
|
285
|
+
observedAt: Date.now(),
|
|
286
|
+
};
|
|
287
|
+
}
|