@fantastic.dev/better-stripe 0.25.0 → 0.26.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 (99) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +11 -0
  3. package/README.md +4 -1
  4. package/dist/client/connect/payoutExecution.d.ts.map +1 -1
  5. package/dist/client/connect/payoutExecution.js +8 -83
  6. package/dist/client/connect/payoutExecution.js.map +1 -1
  7. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +2 -10
  8. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
  9. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
  10. package/dist/client/connect/payoutPreview.d.ts +20 -0
  11. package/dist/client/connect/payoutPreview.d.ts.map +1 -0
  12. package/dist/client/connect/payoutPreview.js +44 -0
  13. package/dist/client/connect/payoutPreview.js.map +1 -0
  14. package/dist/client/connect/payoutQualification.d.ts +30 -0
  15. package/dist/client/connect/payoutQualification.d.ts.map +1 -0
  16. package/dist/client/connect/payoutQualification.js +98 -0
  17. package/dist/client/connect/payoutQualification.js.map +1 -0
  18. package/dist/client/index.d.ts +0 -3
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/index.js +2 -4
  21. package/dist/client/index.js.map +1 -1
  22. package/dist/client/payoutPolicy.d.ts +3 -0
  23. package/dist/client/payoutPolicy.d.ts.map +1 -1
  24. package/dist/client/payoutPolicy.js +4 -0
  25. package/dist/client/payoutPolicy.js.map +1 -1
  26. package/dist/client/readContracts.d.ts +3 -0
  27. package/dist/client/readContracts.d.ts.map +1 -1
  28. package/dist/client/readContracts.js +4 -0
  29. package/dist/client/readContracts.js.map +1 -1
  30. package/dist/client/setup.test.d.ts +18 -0
  31. package/dist/client/setup.test.d.ts.map +1 -0
  32. package/dist/client/setup.test.js +33 -0
  33. package/dist/client/setup.test.js.map +1 -0
  34. package/dist/client/types/triggers.d.ts +1 -1
  35. package/dist/client/types/triggers.d.ts.map +1 -1
  36. package/dist/client/webhooks/helpers.d.ts.map +1 -1
  37. package/dist/client/webhooks/helpers.js +1 -0
  38. package/dist/client/webhooks/helpers.js.map +1 -1
  39. package/dist/client/webhooks/payoutReconciliation.d.ts +8 -0
  40. package/dist/client/webhooks/payoutReconciliation.d.ts.map +1 -0
  41. package/dist/client/webhooks/payoutReconciliation.js +36 -0
  42. package/dist/client/webhooks/payoutReconciliation.js.map +1 -0
  43. package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts +117 -0
  44. package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts.map +1 -0
  45. package/dist/client/webhooks/payoutReconciliationTestHelpers.js +105 -0
  46. package/dist/client/webhooks/payoutReconciliationTestHelpers.js.map +1 -0
  47. package/dist/client/webhooks/processors.d.ts.map +1 -1
  48. package/dist/client/webhooks/processors.js +1 -20
  49. package/dist/client/webhooks/processors.js.map +1 -1
  50. package/dist/component/_generated/component.d.ts +39 -0
  51. package/dist/component/_generated/component.d.ts.map +1 -1
  52. package/dist/component/affiliateRecovery/accounting.d.ts +2 -2
  53. package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -1
  54. package/dist/component/affiliateRecovery/accounting.js.map +1 -1
  55. package/dist/component/payoutRelease/helpers.d.ts +4 -4
  56. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  57. package/dist/component/payoutRelease/helpers.js.map +1 -1
  58. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  59. package/dist/component/payoutRelease/mutations.js +2 -29
  60. package/dist/component/payoutRelease/mutations.js.map +1 -1
  61. package/dist/component/payoutRelease/planning.d.ts +27 -0
  62. package/dist/component/payoutRelease/planning.d.ts.map +1 -0
  63. package/dist/component/payoutRelease/planning.js +35 -0
  64. package/dist/component/payoutRelease/planning.js.map +1 -0
  65. package/dist/component/payoutRelease/preview.d.ts +117 -0
  66. package/dist/component/payoutRelease/preview.d.ts.map +1 -0
  67. package/dist/component/payoutRelease/preview.js +82 -0
  68. package/dist/component/payoutRelease/preview.js.map +1 -0
  69. package/dist/component/payoutRelease/queries.d.ts +39 -0
  70. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  71. package/dist/component/payoutRelease/queries.js +6 -0
  72. package/dist/component/payoutRelease/queries.js.map +1 -1
  73. package/docs/read-contracts.md +21 -5
  74. package/docs/settlement.md +57 -0
  75. package/package.json +1 -1
  76. package/src/client/connect/payoutExecution.ts +12 -99
  77. package/src/client/connect/payoutExecutionTestHelpers.ts +15 -13
  78. package/src/client/connect/payoutPreview.test.ts +269 -0
  79. package/src/client/connect/payoutPreview.ts +77 -0
  80. package/src/client/connect/payoutQualification.ts +145 -0
  81. package/src/client/index.ts +3 -6
  82. package/src/client/payoutPolicy.ts +14 -0
  83. package/src/client/readContracts.ts +5 -0
  84. package/src/client/types/triggers.ts +1 -0
  85. package/src/client/webhooks/helpers.ts +1 -0
  86. package/src/client/webhooks/payout-canceled.integration.test.ts +14 -1
  87. package/src/client/webhooks/payout-reconciliation.integration.test.ts +168 -0
  88. package/src/client/webhooks/payoutReconciliation.ts +72 -0
  89. package/src/client/webhooks/payoutReconciliationTestHelpers.ts +135 -0
  90. package/src/client/webhooks/processors.test.ts +44 -29
  91. package/src/client/webhooks/processors.ts +1 -27
  92. package/src/component/_generated/api.ts +4 -0
  93. package/src/component/_generated/component.ts +42 -0
  94. package/src/component/affiliateRecovery/accounting.ts +2 -2
  95. package/src/component/payoutRelease/helpers.ts +7 -4
  96. package/src/component/payoutRelease/mutations.ts +2 -37
  97. package/src/component/payoutRelease/planning.ts +55 -0
  98. package/src/component/payoutRelease/preview.ts +118 -0
  99. package/src/component/payoutRelease/queries.ts +6 -0
@@ -0,0 +1,145 @@
1
+ import type Stripe from "stripe";
2
+ import type { Infer } from "convex/values";
3
+ import type { Component, RunCtx } from "../helpers.js";
4
+ import { componentRef } from "../webhooks/helpers.js";
5
+ import type {
6
+ claimDoc,
7
+ observation,
8
+ } from "../../component/payoutRelease/schema.js";
9
+ import type { transferDocValidator } from "../../component/connect/validators.js";
10
+ import type { policyDoc } from "../../component/payoutPolicy/schema.js";
11
+ import { transferFingerprint } from "../../component/payoutRelease/helpers.js";
12
+ import {
13
+ getTransferFundingReceipt,
14
+ type TransferReceiptTransaction,
15
+ } from "./transferReceipt.js";
16
+ import { verifyPayoutHistory } from "./payoutHistory.js";
17
+ import { reconcileRecipientPayout } from "./payoutReconciliation.js";
18
+ type Held = { status: "held"; reason: string };
19
+ type Snapshot =
20
+ | {
21
+ status: "ready";
22
+ transfers: Infer<typeof transferDocValidator>[];
23
+ claims: Infer<typeof claimDoc>[];
24
+ }
25
+ | Held;
26
+ export type PayoutControlVerification = () => Promise<
27
+ { status: "verified"; policy: Infer<typeof policyDoc> } | { status: string }
28
+ >;
29
+ export type PayoutProofScope = {
30
+ accountId: string;
31
+ ownerKey: string;
32
+ currency: string;
33
+ };
34
+ /** Shared provider qualification. It can reconcile existing receipts, but never creates a bank payout. */
35
+ export async function collectPayoutQualification(
36
+ stripe: Stripe,
37
+ component: Component,
38
+ ctx: RunCtx,
39
+ opts: PayoutProofScope,
40
+ maxPages: number,
41
+ ): Promise<
42
+ | Held
43
+ | {
44
+ status: "verified";
45
+ observations: Infer<typeof observation>[];
46
+ destinationId: string;
47
+ providerAvailable: number;
48
+ observedAt: number;
49
+ }
50
+ > {
51
+ const snapshot = (await ctx.runQuery(
52
+ componentRef(component, "payoutRelease/queries/snapshot"),
53
+ { accountId: opts.accountId, ownerKey: opts.ownerKey },
54
+ )) as Snapshot;
55
+ if (snapshot.status !== "ready") return snapshot;
56
+ const banks: string[] = [];
57
+ let bankCursor: string | undefined;
58
+ for (let page = 0; ; page++) {
59
+ if (page >= maxPages)
60
+ return { status: "held", reason: "bank_history_incomplete" };
61
+ const batch = await stripe.accounts.listExternalAccounts(opts.accountId, {
62
+ object: "bank_account",
63
+ limit: 100,
64
+ ...(bankCursor ? { starting_after: bankCursor } : {}),
65
+ });
66
+ for (const bank of batch.data)
67
+ if (
68
+ bank.object === "bank_account" &&
69
+ bank.currency === opts.currency &&
70
+ bank.default_for_currency &&
71
+ bank.status !== "errored"
72
+ )
73
+ banks.push(bank.id);
74
+ if (!batch.has_more) break;
75
+ const next = batch.data[batch.data.length - 1]?.id;
76
+ if (!next || next === bankCursor)
77
+ return { status: "held", reason: "bank_history_incomplete" };
78
+ bankCursor = next;
79
+ }
80
+ if (banks.length !== 1 || !/^ba_\w+$/.test(banks[0]))
81
+ return { status: "held", reason: "bank_destination_unavailable" };
82
+ const destinationId = banks[0];
83
+ const transactions: TransferReceiptTransaction[] = [];
84
+ for (const claim of snapshot.claims) {
85
+ if (
86
+ claim.currency !== opts.currency ||
87
+ claim.status === "canceled" ||
88
+ claim.status === "rejected"
89
+ )
90
+ continue;
91
+ if (claim.attemptedAt === undefined)
92
+ return { status: "held", reason: "pending_claim" };
93
+ const result = await reconcileRecipientPayout(stripe, component, ctx, {
94
+ accountId: opts.accountId,
95
+ ownerKey: opts.ownerKey,
96
+ operationKey: claim.operationKey,
97
+ maxPages,
98
+ });
99
+ if (result.status !== "updated")
100
+ return {
101
+ status: "held" as const,
102
+ reason:
103
+ result.status === "held"
104
+ ? result.reason
105
+ : "historical_claim_terminal",
106
+ };
107
+ for (const t of [result.receipt.debit, result.receipt.credit])
108
+ if (t)
109
+ transactions.push({ ...t, sourceId: result.receipt.stripePayoutId });
110
+ }
111
+ const observations = [];
112
+ for (const row of snapshot.transfers) {
113
+ const receipt = await getTransferFundingReceipt(stripe, component, ctx, {
114
+ accountId: opts.accountId,
115
+ stripeTransferId: row.stripeTransferId,
116
+ });
117
+ if (receipt.status !== "ready")
118
+ return { status: "held", reason: receipt.reason };
119
+ observations.push({
120
+ stripeTransferId: row.stripeTransferId,
121
+ currency: receipt.settlementCurrency,
122
+ matureNet: receipt.matureNet,
123
+ pendingCredits: receipt.pendingCredits,
124
+ ledgerFingerprint: transferFingerprint(row),
125
+ observedAt: receipt.observedAt,
126
+ transactionIds: receipt.transactions.map((t) => t.id),
127
+ });
128
+ if (receipt.settlementCurrency === opts.currency)
129
+ transactions.push(...receipt.transactions);
130
+ }
131
+ const history = await verifyPayoutHistory(stripe, {
132
+ accountId: opts.accountId,
133
+ currency: opts.currency,
134
+ transactions,
135
+ maxPages,
136
+ });
137
+ if (history.status !== "verified") return history;
138
+ return {
139
+ status: "verified",
140
+ observations,
141
+ destinationId,
142
+ providerAvailable: history.providerAvailable,
143
+ observedAt: history.observedAt,
144
+ };
145
+ }
@@ -1,3 +1,4 @@
1
+ import { reconcilePayoutAndTrigger } from "./webhooks/payoutReconciliation.js";
1
2
  import { AffiliateRecoveryClient } from "./affiliateRecovery.js";
2
3
  export * from "./affiliateRecovery.js";
3
4
  import { CheckoutRequestClient } from "./checkoutRequests.js";
@@ -934,12 +935,6 @@ export class BetterStripe extends ReadContractClient {
934
935
  return subscriptionsImpl.upsertSubscription(this.component, ctx, opts);
935
936
  }
936
937
 
937
- async getTrialStatus(ctx: RunCtx, opts: { subscriptionId: string }) {
938
- return subscriptionsImpl.getTrialStatus(this.component, ctx, opts);
939
- }
940
-
941
- // INVOICE METHODS
942
-
943
938
  async listInvoices(
944
939
  ctx: RunCtx,
945
940
  opts?: {
@@ -1775,6 +1770,8 @@ export class BetterStripe extends ReadContractClient {
1775
1770
  splitCreateUpdate(triggers?.payment?.onCreate, undefined),
1776
1771
  record,
1777
1772
  ),
1773
+ payoutReconciled: (ctx, record) =>
1774
+ reconcilePayoutAndTrigger(component, ctx, record, triggers?.payout),
1778
1775
  payoutUpserted: (ctx, record) =>
1779
1776
  runUpsertAndTrigger<StripeComponentPayout>(
1780
1777
  ctx,
@@ -1,3 +1,8 @@
1
+ import {
2
+ previewRecipientPayout,
3
+ type PayoutPreviewOptions,
4
+ } from "./connect/payoutPreview.js";
5
+ export type * from "./connect/payoutPreview.js";
1
6
  import { PayoutWorkerClient } from "./connect/payoutWorker.js";
2
7
  export type * from "./connect/payoutWorker.js";
3
8
  import {
@@ -107,6 +112,15 @@ const toProvider = (
107
112
  /** Host authenticates owner/policy/consent. Provider I/O stays in this package. */
108
113
  export class PayoutPolicyClient {
109
114
  readonly worker: PayoutWorkerClient;
115
+ preview(ctx: RunCtx, opts: PayoutPreviewOptions) {
116
+ return previewRecipientPayout(
117
+ this.provider(),
118
+ this.component,
119
+ ctx,
120
+ opts,
121
+ () => this.verify(ctx, opts),
122
+ );
123
+ }
110
124
  constructor(
111
125
  private readonly component: Component,
112
126
  private readonly provider: () => Stripe,
@@ -1,4 +1,5 @@
1
1
  import * as checkoutImpl from "./billing/checkout.js";
2
+ import * as subscriptionsImpl from "./billing/subscriptions.js";
2
3
  import * as payoutsImpl from "./connect/payouts.js";
3
4
  import type { ComponentApi } from "../component/_generated/component.js";
4
5
  import type { PaginationOptions, PaginationResult } from "convex/server";
@@ -75,6 +76,10 @@ export class ReadContractClient {
75
76
  return checkoutImpl.listCheckoutSessionsByUser(this.component, ctx, opts);
76
77
  }
77
78
 
79
+ async getTrialStatus(ctx: RunCtx, opts: { subscriptionId: string }) {
80
+ return subscriptionsImpl.getTrialStatus(this.component, ctx, opts);
81
+ }
82
+
78
83
  async getPayout(ctx: RunCtx, opts: { payoutId: string }) {
79
84
  return payoutsImpl.getPayout(this.component, ctx, opts);
80
85
  }
@@ -243,6 +243,7 @@ export type TriggerDispatcherName =
243
243
  | "invoiceUpserted"
244
244
  | "paymentUpserted"
245
245
  | "payoutUpserted"
246
+ | "payoutReconciled"
246
247
  | "refundUpserted"
247
248
  | "disputeUpserted";
248
249
 
@@ -207,6 +207,7 @@ export const DISPATCHER_UPSERT_PATHS: Record<TriggerDispatcherName, string> = {
207
207
  invoiceUpserted: "billing/mutations/upsertInvoice",
208
208
  paymentUpserted: "connect/mutations/upsertPayment",
209
209
  payoutUpserted: "connect/mutations/upsertPayout",
210
+ payoutReconciled: "payoutSync/mutations/finish",
210
211
  refundUpserted: "connect/mutations/upsertRefund",
211
212
  disputeUpserted: "connect/mutations/upsertDispute",
212
213
  };
@@ -85,6 +85,11 @@ function fixture(
85
85
  },
86
86
  },
87
87
  };
88
+ const retrieve = vi
89
+ .spyOn(getStripeClient(key).payouts, "retrieve")
90
+ .mockImplementation(
91
+ async () => ({ ...event.data.object }) as Stripe.Response<Stripe.Payout>,
92
+ );
88
93
  const payout = () => t.query(payoutRef, { stripePayoutId: "po_qa_audit" });
89
94
  const deliver = async (config: RegisterRoutesConfig = {}, tamper = false) => {
90
95
  const body = JSON.stringify(event);
@@ -106,7 +111,7 @@ function fixture(
106
111
  return { status: response.status, body: await response.json() };
107
112
  });
108
113
  };
109
- return { t, event, payout, deliver };
114
+ return { t, event, payout, deliver, retrieve };
110
115
  }
111
116
 
112
117
  it("reconciles signed cancellation before callbacks and deduplicates replay", async () => {
@@ -158,6 +163,7 @@ it("reconciles signed cancellation before callbacks and deduplicates replay", as
158
163
  await f.t.query(ledgerRef, { stripeEventId: f.event.id }),
159
164
  ).toMatchObject({ status: "processed" });
160
165
  expect(transport).not.toHaveBeenCalled();
166
+ expect(f.retrieve).toHaveBeenCalledTimes(2);
161
167
  });
162
168
 
163
169
  it("rejects a tampered cancellation before storage or callbacks", async () => {
@@ -167,6 +173,7 @@ it("rejects a tampered cancellation before storage or callbacks", async () => {
167
173
  expect(await f.payout()).toBeNull();
168
174
  expect(await f.t.query(ledgerRef, { stripeEventId: f.event.id })).toBeNull();
169
175
  expect(onEvent).not.toHaveBeenCalled();
176
+ expect(f.retrieve).not.toHaveBeenCalled();
170
177
  expect(transport).not.toHaveBeenCalled();
171
178
  });
172
179
 
@@ -188,6 +195,12 @@ it.each([
188
195
  });
189
196
  expect(await f.payout()).toEqual(first);
190
197
  expect(transport).not.toHaveBeenCalled();
198
+ expect(f.retrieve).toHaveBeenCalledOnce();
199
+ expect(f.retrieve).toHaveBeenCalledWith(
200
+ "po_qa_audit",
201
+ {},
202
+ { stripeAccount: "acct_qa_recipient" },
203
+ );
191
204
  },
192
205
  );
193
206
 
@@ -0,0 +1,168 @@
1
+ // @vitest-environment edge-runtime
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import {
4
+ payoutFixture,
5
+ observationRef,
6
+ } from "./payoutReconciliationTestHelpers.js";
7
+ afterEach(() => vi.restoreAllMocks());
8
+ it("uses current provider state instead of a signed older pending body and deduplicates replay", async () => {
9
+ const f = payoutFixture();
10
+ const observed: string[] = [];
11
+ const config = {
12
+ onEvent: async () => {
13
+ observed.push((await f.row())!.status);
14
+ },
15
+ };
16
+ expect((await f.deliver(config)).status).toBe(200);
17
+ expect(await f.row()).toMatchObject({
18
+ status: "paid",
19
+ accountId: "acct_recipient",
20
+ });
21
+ expect(observed).toEqual(["paid"]);
22
+ expect(f.retrieve).toHaveBeenCalledWith(
23
+ "po_current",
24
+ {},
25
+ { stripeAccount: "acct_recipient" },
26
+ );
27
+ expect(await f.deliver(config)).toMatchObject({
28
+ status: 200,
29
+ body: { deduplicated: true },
30
+ });
31
+ expect(f.retrieve).toHaveBeenCalledTimes(1);
32
+ expect(observed).toEqual(["paid"]);
33
+ });
34
+ it("holds unavailable provider state without applying the signed body or calling application hooks", async () => {
35
+ const f = payoutFixture();
36
+ const onEvent = vi.fn();
37
+ f.retrieve.mockRejectedValueOnce(new Error("Provider unavailable"));
38
+ expect((await f.deliver({ onEvent })).status).toBe(500);
39
+ expect(await f.row()).toBeNull();
40
+ expect(onEvent).not.toHaveBeenCalled();
41
+ expect(await f.ledger()).toMatchObject({ status: "failed" });
42
+ expect((await f.deliver({ onEvent })).status).toBe(200);
43
+ expect(await f.row()).toMatchObject({ status: "paid" });
44
+ });
45
+
46
+ it("commits synchronized callbacks with the accepted generation and rolls back callback failures", async () => {
47
+ let canCommit = false;
48
+ const f = payoutFixture({
49
+ payout: {
50
+ onCreate: async (ctx, doc) => {
51
+ await ctx.runMutation(observationRef, { status: doc.status });
52
+ if (!canCommit) throw new Error("Callback failed");
53
+ },
54
+ onUpdate: async (ctx, doc, old) => {
55
+ await ctx.runMutation(observationRef, {
56
+ status: doc.status,
57
+ oldStatus: old.status,
58
+ });
59
+ },
60
+ },
61
+ });
62
+ expect((await f.deliver()).status).toBe(500);
63
+ expect(await f.row()).toBeNull();
64
+ expect(
65
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
66
+ ).toEqual([]);
67
+ canCommit = true;
68
+ expect((await f.deliver()).status).toBe(200);
69
+ expect(await f.row()).toMatchObject({ status: "paid" });
70
+ expect(
71
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
72
+ ).toHaveLength(1);
73
+ f.event.id = "evt_same_current";
74
+ expect((await f.deliver()).status).toBe(200);
75
+ expect(
76
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
77
+ ).toHaveLength(1);
78
+ f.set({
79
+ status: "failed",
80
+ failure_code: "account_closed",
81
+ failure_message: "Closed",
82
+ });
83
+ f.event.id = "evt_later_failure";
84
+ expect((await f.deliver()).status).toBe(200);
85
+ expect(await f.row()).toMatchObject({
86
+ status: "failed",
87
+ failureCode: "account_closed",
88
+ });
89
+ expect(
90
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
91
+ ).toMatchObject([
92
+ { status: "paid" },
93
+ { status: "failed", oldStatus: "paid" },
94
+ ]);
95
+ });
96
+
97
+ it("prevents a delayed old provider response from repeating or regressing a synchronized callback", async () => {
98
+ const f = payoutFixture({
99
+ payout: {
100
+ onCreate: async (ctx, doc) => {
101
+ await ctx.runMutation(observationRef, { status: doc.status });
102
+ },
103
+ onUpdate: async (ctx, doc, old) => {
104
+ await ctx.runMutation(observationRef, {
105
+ status: doc.status,
106
+ oldStatus: old.status,
107
+ });
108
+ },
109
+ },
110
+ });
111
+ let resolve!: (value: ReturnType<typeof f.current>) => void;
112
+ const response = new Promise<ReturnType<typeof f.current>>((done) => {
113
+ resolve = done;
114
+ });
115
+ f.retrieve.mockImplementationOnce(async () => (await response) as never);
116
+ const old = { ...f.current(), status: "pending" as const };
117
+ const first = f.deliver();
118
+ await vi.waitFor(() => expect(f.retrieve).toHaveBeenCalledTimes(1));
119
+ f.event.id = "evt_concurrent_newer";
120
+ expect((await f.deliver()).status).toBe(200);
121
+ resolve(old);
122
+ expect((await first).status).toBe(200);
123
+ expect(await f.row()).toMatchObject({ status: "paid" });
124
+ expect(
125
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
126
+ ).toMatchObject([{ status: "paid" }]);
127
+ });
128
+ it("preserves an accepted row when a later provider read fails", async () => {
129
+ const f = payoutFixture();
130
+ await f.deliver();
131
+ const original = await f.row();
132
+ f.event.id = "evt_provider_unavailable_later";
133
+ f.retrieve.mockRejectedValueOnce(new Error("Provider unavailable"));
134
+ expect((await f.deliver()).status).toBe(500);
135
+ expect(await f.row()).toEqual(original);
136
+ expect((await f.deliver()).status).toBe(200);
137
+ expect(await f.row()).toEqual(original);
138
+ });
139
+ it("uses explicit platform scope without adopting metadata or bank account IDs", async () => {
140
+ const f = payoutFixture();
141
+ f.event.account = undefined;
142
+ expect((await f.deliver()).status).toBe(200);
143
+ expect(await f.row()).toMatchObject({
144
+ accountId: "platform",
145
+ status: "paid",
146
+ });
147
+ expect(f.retrieve).toHaveBeenCalledWith("po_current", {}, {});
148
+ });
149
+ it("rejects a foreign signed account for an existing payout before any provider read or callback", async () => {
150
+ const f = payoutFixture();
151
+ await f.deliver();
152
+ const original = await f.row();
153
+ f.event.id = "evt_foreign_account";
154
+ f.event.account = "acct_foreign";
155
+ const onEvent = vi.fn();
156
+ expect((await f.deliver({ onEvent })).status).toBe(500);
157
+ expect(await f.row()).toEqual(original);
158
+ expect(f.retrieve).toHaveBeenCalledTimes(1);
159
+ expect(onEvent).not.toHaveBeenCalled();
160
+ });
161
+ it("rejects a provider response for a different payout without adopting it", async () => {
162
+ const f = payoutFixture();
163
+ f.set({ id: "po_foreign" });
164
+ const onEvent = vi.fn();
165
+ expect((await f.deliver({ onEvent })).status).toBe(500);
166
+ expect(await f.row()).toBeNull();
167
+ expect(onEvent).not.toHaveBeenCalled();
168
+ });
@@ -0,0 +1,72 @@
1
+ import type Stripe from "stripe";
2
+ import type { Component } from "../helpers.js";
3
+ import type {
4
+ SyncTriggerCtx,
5
+ SyncTriggers,
6
+ StripeComponentPayout,
7
+ } from "../types.js";
8
+ import {
9
+ preparePayoutSync,
10
+ type PayoutSyncResult,
11
+ type PreparedPayoutSync,
12
+ } from "../connect/payoutSync.js";
13
+ import {
14
+ componentRef,
15
+ dispatchUpsert,
16
+ type WebhookContext,
17
+ } from "./helpers.js";
18
+
19
+ export async function handlePayoutEvent(
20
+ whCtx: WebhookContext,
21
+ payout: Stripe.Payout,
22
+ accountId?: string,
23
+ ) {
24
+ const prepared = await preparePayoutSync(
25
+ whCtx.stripe,
26
+ whCtx.component,
27
+ whCtx.ctx,
28
+ {
29
+ stripePayoutId: payout.id,
30
+ scope:
31
+ accountId === undefined
32
+ ? { type: "platform" }
33
+ : { type: "connected", accountId },
34
+ },
35
+ );
36
+ try {
37
+ await dispatchUpsert(whCtx, "payoutReconciled", prepared);
38
+ } catch (error) {
39
+ await whCtx.ctx.runMutation(
40
+ componentRef(whCtx.component, "payoutSync/mutations/markUnknown"),
41
+ {
42
+ stripePayoutId: prepared.stripePayoutId,
43
+ accountId: prepared.accountId,
44
+ generation: prepared.generation,
45
+ },
46
+ );
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ /** The host mutation commits the generation and application callback together. */
52
+ export async function reconcilePayoutAndTrigger(
53
+ component: Component,
54
+ ctx: SyncTriggerCtx,
55
+ record: Record<string, unknown>,
56
+ triggers: SyncTriggers["payout"],
57
+ ) {
58
+ const prepared = record as PreparedPayoutSync;
59
+ const old = triggers
60
+ ? ((await ctx.runQuery(
61
+ componentRef(component, "connect/queries/getPayoutByStripeId"),
62
+ { stripePayoutId: prepared.stripePayoutId },
63
+ )) as StripeComponentPayout | null)
64
+ : null;
65
+ const result = (await ctx.runMutation(
66
+ componentRef(component, "payoutSync/mutations/finish"),
67
+ prepared,
68
+ )) as PayoutSyncResult;
69
+ if (result.status !== "accepted" || !result.changed) return;
70
+ if (old) await triggers?.onUpdate?.(ctx, result.payout, old);
71
+ else await triggers?.onCreate?.(ctx, result.payout);
72
+ }
@@ -0,0 +1,135 @@
1
+ // @vitest-environment edge-runtime
2
+ import Stripe from "stripe";
3
+ import { vi } from "vitest";
4
+ import schema from "../../component/schema.js";
5
+ import { convexTest } from "convex-test";
6
+ import {
7
+ defineSchema,
8
+ defineTable,
9
+ makeFunctionReference,
10
+ internalMutationGeneric,
11
+ } from "convex/server";
12
+ import { v } from "convex/values";
13
+ import { BetterStripe } from "../index.js";
14
+ import { components } from "../setup.test.js";
15
+ import { getStripeClient } from "../helpers.js";
16
+ import type {
17
+ SyncTriggers,
18
+ RegisterRoutesConfig,
19
+ WebhookHandlerRefs,
20
+ } from "../types.js";
21
+ import { handleWebhookRequest } from "./handler.js";
22
+ export const observationRef = makeFunctionReference<
23
+ "mutation",
24
+ { status: string; oldStatus?: string },
25
+ null
26
+ >("payoutHost:recordObservation");
27
+ const modules = import.meta.glob("../../component/**/*.{ts,js,tsx,jsx}");
28
+ const key = "sk_test_signed_authoritative_payout";
29
+ const secret = "whsec_signed_authoritative_payout";
30
+ export function payoutFixture(triggers?: SyncTriggers) {
31
+ const handlers = new BetterStripe(components.betterStripe, {
32
+ triggers,
33
+ }).webhookHandlers();
34
+ const t = convexTest(
35
+ defineSchema({
36
+ observations: defineTable({
37
+ status: v.string(),
38
+ oldStatus: v.optional(v.string()),
39
+ }),
40
+ }),
41
+ {
42
+ "./_generated/server.ts": () =>
43
+ import("../../component/_generated/server.js"),
44
+ "./payoutHost.ts": async () => ({
45
+ ...handlers,
46
+ recordObservation: internalMutationGeneric({
47
+ args: { status: v.string(), oldStatus: v.optional(v.string()) },
48
+ returns: v.null(),
49
+ handler: async (ctx, args) => {
50
+ await ctx.db.insert("observations", args);
51
+ return null;
52
+ },
53
+ }),
54
+ }),
55
+ },
56
+ );
57
+ t.registerComponent("betterStripe", schema, modules);
58
+ const webhooks: WebhookHandlerRefs | undefined = triggers
59
+ ? {
60
+ syncWebhook: makeFunctionReference<"mutation">(
61
+ "payoutHost:syncWebhook",
62
+ ) as unknown as WebhookHandlerRefs["syncWebhook"],
63
+ asyncWebhook: makeFunctionReference<"action">(
64
+ "payoutHost:asyncWebhook",
65
+ ) as unknown as WebhookHandlerRefs["asyncWebhook"],
66
+ }
67
+ : undefined;
68
+ let current = {
69
+ id: "po_current",
70
+ object: "payout",
71
+ amount: 1200,
72
+ currency: "usd",
73
+ status: "paid",
74
+ arrival_date: 1792108800,
75
+ automatic: true,
76
+ method: "standard",
77
+ failure_code: null,
78
+ failure_message: null,
79
+ metadata: { accountId: "acct_untrusted" },
80
+ } as unknown as Stripe.Payout;
81
+ const retrieve = vi
82
+ .spyOn(getStripeClient(key).payouts, "retrieve")
83
+ .mockImplementation(
84
+ async () => ({ ...current }) as Stripe.Response<Stripe.Payout>,
85
+ );
86
+ const event = {
87
+ id: "evt_payout_first",
88
+ object: "event",
89
+ type: "payout.updated",
90
+ created: 1792010000,
91
+ livemode: false,
92
+ account: "acct_recipient" as string | undefined,
93
+ data: { object: { ...current, status: "pending" } },
94
+ };
95
+ const row = () =>
96
+ t.query(components.betterStripe.connect.queries.getPayoutByStripeId, {
97
+ stripePayoutId: "po_current",
98
+ });
99
+ const ledger = () =>
100
+ t.query(components.betterStripe.webhooks.queries.getWebhookEvent, {
101
+ stripeEventId: event.id,
102
+ });
103
+ const deliver = async (config: RegisterRoutesConfig = {}) => {
104
+ const body = JSON.stringify(event);
105
+ const signature = await Stripe.webhooks.generateTestHeaderStringAsync({
106
+ payload: body,
107
+ secret,
108
+ });
109
+ return t.action(async (ctx) => {
110
+ const response = await handleWebhookRequest(
111
+ ctx,
112
+ new Request("https://example.test/stripe/webhook", {
113
+ method: "POST",
114
+ body,
115
+ headers: { "stripe-signature": signature },
116
+ }),
117
+ components.betterStripe,
118
+ { stripeSecretKey: key, webhookSecret: secret, webhooks, ...config },
119
+ );
120
+ return { status: response.status, body: await response.json() };
121
+ });
122
+ };
123
+ return {
124
+ t,
125
+ event,
126
+ deliver,
127
+ row,
128
+ ledger,
129
+ retrieve,
130
+ current: () => ({ ...current }),
131
+ set: (patch: Partial<Stripe.Payout>) => {
132
+ current = { ...current, ...patch };
133
+ },
134
+ };
135
+ }