@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +10 -1
  3. package/dist/client/connect/payouts.d.ts +3 -2
  4. package/dist/client/connect/payouts.d.ts.map +1 -1
  5. package/dist/client/connect/payouts.js.map +1 -1
  6. package/dist/client/connect/transferReceipt.d.ts +48 -0
  7. package/dist/client/connect/transferReceipt.d.ts.map +1 -0
  8. package/dist/client/connect/transferReceipt.js +296 -0
  9. package/dist/client/connect/transferReceipt.js.map +1 -0
  10. package/dist/client/index.d.ts +4 -8
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +5 -0
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/payoutPolicy.d.ts +84 -0
  15. package/dist/client/payoutPolicy.d.ts.map +1 -0
  16. package/dist/client/payoutPolicy.js +266 -0
  17. package/dist/client/payoutPolicy.js.map +1 -0
  18. package/dist/component/_generated/component.d.ts +273 -0
  19. package/dist/component/_generated/component.d.ts.map +1 -1
  20. package/dist/component/earnings/triggers.d.ts +150 -0
  21. package/dist/component/earnings/triggers.d.ts.map +1 -1
  22. package/dist/component/payoutPolicy/helpers.d.ts +51 -0
  23. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -0
  24. package/dist/component/payoutPolicy/helpers.js +68 -0
  25. package/dist/component/payoutPolicy/helpers.js.map +1 -0
  26. package/dist/component/payoutPolicy/mutations.d.ts +172 -0
  27. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -0
  28. package/dist/component/payoutPolicy/mutations.js +191 -0
  29. package/dist/component/payoutPolicy/mutations.js.map +1 -0
  30. package/dist/component/payoutPolicy/queries.d.ts +97 -0
  31. package/dist/component/payoutPolicy/queries.d.ts.map +1 -0
  32. package/dist/component/payoutPolicy/queries.js +51 -0
  33. package/dist/component/payoutPolicy/queries.js.map +1 -0
  34. package/dist/component/payoutPolicy/schema.d.ts +396 -0
  35. package/dist/component/payoutPolicy/schema.d.ts.map +1 -0
  36. package/dist/component/payoutPolicy/schema.js +54 -0
  37. package/dist/component/payoutPolicy/schema.js.map +1 -0
  38. package/dist/component/schema.d.ts +131 -0
  39. package/dist/component/schema.js +3 -0
  40. package/dist/component/schema.js.map +1 -1
  41. package/docs/settlement.md +112 -0
  42. package/package.json +1 -1
  43. package/src/client/connect/payouts.ts +10 -8
  44. package/src/client/connect/transferReceipt.pagination.test.ts +209 -0
  45. package/src/client/connect/transferReceipt.test.ts +520 -0
  46. package/src/client/connect/transferReceipt.ts +422 -0
  47. package/src/client/index.ts +7 -11
  48. package/src/client/payoutPolicy.test.ts +320 -0
  49. package/src/client/payoutPolicy.ts +422 -0
  50. package/src/component/_generated/api.ts +6 -0
  51. package/src/component/_generated/component.ts +280 -0
  52. package/src/component/payout-policy.test.ts +248 -0
  53. package/src/component/payoutPolicy/helpers.ts +88 -0
  54. package/src/component/payoutPolicy/mutations.ts +225 -0
  55. package/src/component/payoutPolicy/queries.ts +56 -0
  56. package/src/component/payoutPolicy/schema.ts +67 -0
  57. package/src/component/schema.ts +3 -0
@@ -0,0 +1,422 @@
1
+ import type Stripe from "stripe";
2
+ import type { Infer } from "convex/values";
3
+ import type { transferDocValidator } from "../../component/connect/validators.js";
4
+ import type { Component, RunCtx } from "../helpers.js";
5
+ import { componentRef } from "../webhooks/helpers.js";
6
+ type LedgerTransfer = Infer<typeof transferDocValidator>;
7
+ export type TransferReceiptOptions = {
8
+ accountId: string;
9
+ stripeTransferId: string;
10
+ maxRequests?: number;
11
+ };
12
+ export type TransferReceiptTransaction = {
13
+ id: string;
14
+ sourceId: string;
15
+ amount: number;
16
+ fee: number;
17
+ net: number;
18
+ currency: string;
19
+ created: number;
20
+ /** Unix seconds, as returned by Stripe. */
21
+ availableOn: number;
22
+ status: "available" | "pending";
23
+ type: string;
24
+ };
25
+ export type TransferReceiptNotReadyReason =
26
+ | "not_in_ledger"
27
+ | "transfer_pending"
28
+ | "receipt_unavailable"
29
+ | "reversal_pending"
30
+ | "history_incomplete"
31
+ | "currency_allocation_required"
32
+ | "provider_changed"
33
+ | "ledger_changed";
34
+ export type TransferFundingReceipt =
35
+ | {
36
+ status: "not_ready";
37
+ accountId: string;
38
+ stripeTransferId: string;
39
+ reason: TransferReceiptNotReadyReason;
40
+ }
41
+ | {
42
+ status: "ready";
43
+ accountId: string;
44
+ stripeTransferId: string;
45
+ destinationPaymentId: string;
46
+ sourceAmount: number;
47
+ sourceCurrency: string;
48
+ settlementCurrency: string;
49
+ providerReversedAmount: number;
50
+ totalNet: number;
51
+ /** Signed source contribution; previous payouts are not allocated here. */
52
+ matureNet: number;
53
+ pendingCredits: number;
54
+ role?: "store" | "affiliate" | "other";
55
+ reinstatement: boolean;
56
+ transactions: TransferReceiptTransaction[];
57
+ /** Snapshot observation time in milliseconds. */
58
+ observedAt: number;
59
+ };
60
+ class NotReady extends Error {
61
+ constructor(readonly reason: TransferReceiptNotReadyReason) {
62
+ super(reason);
63
+ }
64
+ }
65
+ const id = (value: string | { id: string } | null | undefined) =>
66
+ typeof value === "string" ? value : value?.id;
67
+ function integer(value: number) {
68
+ if (!Number.isSafeInteger(value))
69
+ throw new Error("Receipt amounts must be safe integers.");
70
+ return value;
71
+ }
72
+ const add = (a: number, b: number) => integer(a + b);
73
+ function fingerprint(t: Stripe.Transfer) {
74
+ return JSON.stringify([
75
+ t.id,
76
+ id(t.destination),
77
+ id(t.destination_payment),
78
+ id(t.source_transaction),
79
+ t.amount,
80
+ t.currency,
81
+ t.amount_reversed,
82
+ t.metadata?.bsReinstateOf,
83
+ ]);
84
+ }
85
+ function ledgerFingerprint(t: LedgerTransfer) {
86
+ return JSON.stringify([
87
+ t.stripeTransferId,
88
+ t.destinationAccountId,
89
+ t.sourceChargeId,
90
+ t.amount,
91
+ t.currency,
92
+ t.role,
93
+ t.status,
94
+ t.reversedAmount ?? 0,
95
+ t.reversalClaimedAmount ?? 0,
96
+ t.reinstatement ?? false,
97
+ ]);
98
+ }
99
+ /** Recipient-scoped received credit. Previous payouts are deliberately not allocated here. */
100
+ export async function getTransferFundingReceipt(
101
+ stripe: Stripe,
102
+ component: Component,
103
+ ctx: RunCtx,
104
+ opts: TransferReceiptOptions,
105
+ ): Promise<TransferFundingReceipt> {
106
+ const max = opts.maxRequests ?? 20;
107
+ if (!Number.isInteger(max) || max < 1 || max > 100)
108
+ throw new Error("Receipt request budget must be between 1 and 100.");
109
+ if (
110
+ !/^acct_\w+$/.test(opts.accountId) ||
111
+ !/^tr_\w+$/.test(opts.stripeTransferId)
112
+ )
113
+ throw new Error("Invalid recipient transfer identity.");
114
+ const scope = {
115
+ accountId: opts.accountId,
116
+ stripeTransferId: opts.stripeTransferId,
117
+ };
118
+ const read = () =>
119
+ ctx.runQuery(
120
+ componentRef(component, "connect/queries/getTransferByStripeId"),
121
+ { stripeTransferId: opts.stripeTransferId },
122
+ ) as Promise<LedgerTransfer | null>;
123
+ const ledger = await read();
124
+ if (!ledger)
125
+ return { status: "not_ready", ...scope, reason: "not_in_ledger" };
126
+ if (
127
+ ledger.destinationAccountId !== opts.accountId ||
128
+ ledger.stripeTransferId !== opts.stripeTransferId
129
+ )
130
+ throw new Error("Transfer ledger identity does not match the recipient.");
131
+ if (!["paid", "reversed"].includes(ledger.status))
132
+ return { status: "not_ready", ...scope, reason: "transfer_pending" };
133
+ let requests = 0;
134
+ async function request<T>(fn: () => Promise<T>): Promise<T> {
135
+ if (requests++ >= max) throw new NotReady("history_incomplete");
136
+ return fn();
137
+ }
138
+ async function scan<T extends { id: string }>(
139
+ fn: (cursor?: string) => Promise<{ data: T[]; has_more: boolean }>,
140
+ ): Promise<T[]> {
141
+ const rows: T[] = [];
142
+ let cursor: string | undefined;
143
+ for (;;) {
144
+ const page = await request(() => fn(cursor));
145
+ rows.push(...page.data);
146
+ if (!page.has_more) return rows;
147
+ const next = page.data[page.data.length - 1]?.id;
148
+ if (!next || next === cursor) throw new NotReady("history_incomplete");
149
+ cursor = next;
150
+ }
151
+ }
152
+ try {
153
+ const transfer = await request(() =>
154
+ stripe.transfers.retrieve(opts.stripeTransferId),
155
+ );
156
+ const destinationPaymentId = id(transfer.destination_payment);
157
+ if (
158
+ transfer.id !== ledger.stripeTransferId ||
159
+ id(transfer.destination) !== ledger.destinationAccountId ||
160
+ transfer.amount !== ledger.amount ||
161
+ transfer.currency !== ledger.currency ||
162
+ !destinationPaymentId
163
+ )
164
+ throw new Error(
165
+ "Provider transfer identity or provenance does not match the ledger.",
166
+ );
167
+ const source = id(transfer.source_transaction);
168
+ if (
169
+ ledger.sourceChargeId &&
170
+ source !== ledger.sourceChargeId &&
171
+ !(
172
+ ledger.reinstatement &&
173
+ !source &&
174
+ transfer.metadata?.bsReinstateOf === ledger.sourceChargeId
175
+ )
176
+ )
177
+ throw new Error("Transfer source provenance does not match the ledger.");
178
+ integer(transfer.amount);
179
+ integer(transfer.amount_reversed);
180
+ if (
181
+ transfer.amount <= 0 ||
182
+ transfer.amount_reversed < 0 ||
183
+ transfer.amount_reversed > transfer.amount
184
+ )
185
+ throw new Error("Invalid transfer receipt amount.");
186
+ for (const amount of [
187
+ ledger.reversedAmount ?? 0,
188
+ ledger.reversalClaimedAmount ?? 0,
189
+ ]) {
190
+ integer(amount);
191
+ if (amount < 0 || amount > transfer.amount)
192
+ throw new Error("Invalid ledger reversal amount.");
193
+ }
194
+ if (
195
+ Math.max(ledger.reversedAmount ?? 0, ledger.reversalClaimedAmount ?? 0) >
196
+ transfer.amount_reversed
197
+ )
198
+ throw new NotReady("reversal_pending");
199
+ const payment = await request(() =>
200
+ stripe.charges.retrieve(
201
+ destinationPaymentId,
202
+ {},
203
+ { stripeAccount: opts.accountId },
204
+ ),
205
+ );
206
+ if (
207
+ payment.id !== destinationPaymentId ||
208
+ id(payment.source_transfer) !== transfer.id
209
+ )
210
+ throw new Error(
211
+ "Recipient payment identity does not match the transfer.",
212
+ );
213
+ if (payment.currency !== transfer.currency)
214
+ throw new NotReady("currency_allocation_required");
215
+ if (payment.amount !== transfer.amount)
216
+ throw new Error("Recipient payment amount does not match the transfer.");
217
+ if (
218
+ !payment.paid ||
219
+ payment.status !== "succeeded" ||
220
+ !id(payment.balance_transaction)
221
+ )
222
+ throw new NotReady("receipt_unavailable");
223
+ const records = new Map<string, TransferReceiptTransaction>();
224
+ let settlementCurrency: string | undefined;
225
+ async function receipts(
226
+ sourceId: string,
227
+ primary: { id: string; amount: number; currency: string; sign: 1 | -1 },
228
+ ) {
229
+ const txs = await scan((cursor) =>
230
+ stripe.balanceTransactions.list(
231
+ {
232
+ source: sourceId,
233
+ limit: 100,
234
+ ...(cursor ? { starting_after: cursor } : {}),
235
+ },
236
+ { stripeAccount: opts.accountId },
237
+ ),
238
+ );
239
+ if (!txs.length) throw new NotReady("receipt_unavailable");
240
+ for (const t of txs) {
241
+ if (id(t.source) !== sourceId)
242
+ throw new Error(
243
+ "Recipient receipt identity does not match its source.",
244
+ );
245
+ for (const amount of [
246
+ t.amount,
247
+ t.fee,
248
+ t.net,
249
+ t.created,
250
+ t.available_on,
251
+ ])
252
+ integer(amount);
253
+ if (
254
+ t.fee < 0 ||
255
+ t.net !== integer(t.amount - t.fee) ||
256
+ t.created < 0 ||
257
+ t.available_on < 0
258
+ )
259
+ throw new Error("Invalid recipient receipt amount.");
260
+ if (t.status !== "available" && t.status !== "pending")
261
+ throw new NotReady("receipt_unavailable");
262
+ if (!/^[a-z]{3}$/.test(t.currency))
263
+ throw new Error("Invalid receipt currency.");
264
+ if ("balance_type" in t && t.balance_type !== "payments")
265
+ throw new NotReady("currency_allocation_required");
266
+ if (settlementCurrency && settlementCurrency !== t.currency)
267
+ throw new NotReady("currency_allocation_required");
268
+ settlementCurrency = t.currency;
269
+ if (t.id === primary.id) {
270
+ if (
271
+ t.currency === primary.currency &&
272
+ t.amount !== primary.sign * primary.amount
273
+ )
274
+ throw new Error(
275
+ "Original recipient receipt amount does not match the payment.",
276
+ );
277
+ if (
278
+ t.currency !== primary.currency &&
279
+ (!Number.isFinite(t.exchange_rate) ||
280
+ !t.exchange_rate ||
281
+ t.exchange_rate <= 0)
282
+ )
283
+ throw new NotReady("currency_allocation_required");
284
+ }
285
+ const record: TransferReceiptTransaction = {
286
+ id: t.id,
287
+ sourceId,
288
+ amount: t.amount,
289
+ fee: t.fee,
290
+ net: t.net,
291
+ currency: t.currency,
292
+ created: t.created,
293
+ availableOn: t.available_on,
294
+ status: t.status,
295
+ type: t.type,
296
+ };
297
+ const prior = records.get(t.id);
298
+ if (prior && JSON.stringify(prior) !== JSON.stringify(record))
299
+ throw new Error("Conflicting recipient receipt identity.");
300
+ records.set(t.id, record);
301
+ }
302
+ if (!records.has(primary.id)) throw new NotReady("receipt_unavailable");
303
+ }
304
+ await receipts(destinationPaymentId, {
305
+ id: id(payment.balance_transaction)!,
306
+ amount: payment.amount,
307
+ currency: payment.currency,
308
+ sign: 1,
309
+ });
310
+ const reversals = await scan((cursor) =>
311
+ stripe.transfers.listReversals(transfer.id, {
312
+ limit: 100,
313
+ ...(cursor ? { starting_after: cursor } : {}),
314
+ }),
315
+ );
316
+ const seenReversals = new Map<string, Stripe.TransferReversal>();
317
+ let reversed = 0;
318
+ for (const r of reversals) {
319
+ if (
320
+ id(r.transfer) !== transfer.id ||
321
+ r.currency !== transfer.currency ||
322
+ !Number.isSafeInteger(r.amount) ||
323
+ r.amount <= 0
324
+ )
325
+ throw new Error("Transfer reversal identity or amount does not match.");
326
+ const prior = seenReversals.get(r.id);
327
+ if (prior) {
328
+ if (
329
+ JSON.stringify([
330
+ prior.amount,
331
+ id(prior.destination_payment_refund),
332
+ ]) !== JSON.stringify([r.amount, id(r.destination_payment_refund)])
333
+ )
334
+ throw new Error("Conflicting reversal receipt identity.");
335
+ continue;
336
+ }
337
+ seenReversals.set(r.id, r);
338
+ reversed = add(reversed, r.amount);
339
+ const refundId = id(r.destination_payment_refund);
340
+ if (!refundId) throw new NotReady("receipt_unavailable");
341
+ const refund = await request(() =>
342
+ stripe.refunds.retrieve(
343
+ refundId,
344
+ {},
345
+ { stripeAccount: opts.accountId },
346
+ ),
347
+ );
348
+ if (
349
+ refund.id !== refundId ||
350
+ id(refund.charge) !== destinationPaymentId ||
351
+ refund.amount !== r.amount ||
352
+ refund.currency !== transfer.currency
353
+ )
354
+ throw new Error(
355
+ "Recipient refund identity does not match its reversal.",
356
+ );
357
+ if (refund.status !== "succeeded") throw new NotReady("reversal_pending");
358
+ const refundTransactionId = id(refund.balance_transaction);
359
+ if (!refundTransactionId) throw new NotReady("receipt_unavailable");
360
+ await receipts(refundId, {
361
+ id: refundTransactionId,
362
+ amount: refund.amount,
363
+ currency: refund.currency,
364
+ sign: -1,
365
+ });
366
+ }
367
+ if (reversed !== transfer.amount_reversed)
368
+ throw new NotReady("provider_changed");
369
+ const latest = await request(() => stripe.transfers.retrieve(transfer.id));
370
+ if (fingerprint(transfer) !== fingerprint(latest))
371
+ throw new NotReady("provider_changed");
372
+ const latestLedger = await read();
373
+ if (
374
+ !latestLedger ||
375
+ ledgerFingerprint(ledger) !== ledgerFingerprint(latestLedger)
376
+ )
377
+ throw new NotReady("ledger_changed");
378
+ let totalNet = 0,
379
+ availableNet = 0,
380
+ pendingCredits = 0,
381
+ pendingDebits = 0;
382
+ const now = Math.floor(Date.now() / 1000);
383
+ const primary = records.get(id(payment.balance_transaction)!)!;
384
+ const primaryPending =
385
+ primary.net > 0 &&
386
+ !(primary.status === "available" && primary.availableOn <= now)
387
+ ? primary.net
388
+ : 0;
389
+ for (const t of records.values()) {
390
+ totalNet = add(totalNet, t.net);
391
+ if (t.status === "available" && t.availableOn <= now)
392
+ availableNet = add(availableNet, t.net);
393
+ else if (t.net > 0) pendingCredits = add(pendingCredits, t.net);
394
+ else pendingDebits = add(pendingDebits, -t.net);
395
+ }
396
+ // Cancel a pending original credit first. Uncovered pending debits reduce
397
+ // mature credit; later positive fee credits never qualify before maturity.
398
+ const canceledPending = Math.min(primaryPending, pendingDebits);
399
+ const matureNet = add(availableNet, -(pendingDebits - canceledPending));
400
+ pendingCredits = add(pendingCredits, -canceledPending);
401
+ return {
402
+ status: "ready",
403
+ ...scope,
404
+ destinationPaymentId,
405
+ sourceAmount: transfer.amount,
406
+ sourceCurrency: transfer.currency,
407
+ settlementCurrency: settlementCurrency!,
408
+ providerReversedAmount: transfer.amount_reversed,
409
+ totalNet,
410
+ matureNet,
411
+ pendingCredits,
412
+ ...(ledger.role ? { role: ledger.role } : {}),
413
+ reinstatement: ledger.reinstatement ?? false,
414
+ transactions: [...records.values()],
415
+ observedAt: Date.now(),
416
+ };
417
+ } catch (error) {
418
+ if (error instanceof NotReady)
419
+ return { status: "not_ready", ...scope, reason: error.reason };
420
+ throw error;
421
+ }
422
+ }
@@ -1,3 +1,5 @@
1
+ import { PayoutPolicyClient } from "./payoutPolicy.js";
2
+ export * from "./payoutPolicy.js";
1
3
  import type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
2
4
  export type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
3
5
  import * as intentsImpl from "./billing/intents.js";
@@ -1146,17 +1148,7 @@ export class BetterStripe extends ReadContractClient {
1146
1148
 
1147
1149
  // PAYOUT METHODS
1148
1150
 
1149
- async createPayout(
1150
- ctx: RunCtx,
1151
- opts: {
1152
- stripeAccountId: string;
1153
- amount: number;
1154
- currency?: string;
1155
- metadata?: Record<string, string>;
1156
- /** Stripe idempotency key for retry-safe payout creation. */
1157
- idempotencyKey?: string;
1158
- },
1159
- ) {
1151
+ async createPayout(ctx: RunCtx, opts: payoutsImpl.CreatePayoutOptions) {
1160
1152
  return payoutsImpl.createPayout(this.stripe(), ctx, opts);
1161
1153
  }
1162
1154
 
@@ -1263,6 +1255,10 @@ export class BetterStripe extends ReadContractClient {
1263
1255
  ) {
1264
1256
  return providerReads.getCouponFromStripe(this.stripe(), opts);
1265
1257
  }
1258
+ get payoutPolicy() {
1259
+ return new PayoutPolicyClient(this.component, () => this.stripe());
1260
+ }
1261
+
1266
1262
  get costRecovery() {
1267
1263
  return new CostRecoveryClient(this.component, () => this.stripe());
1268
1264
  }