@fantastic.dev/better-stripe 0.25.0 → 0.25.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/client/index.d.ts +0 -3
  4. package/dist/client/index.d.ts.map +1 -1
  5. package/dist/client/index.js +2 -4
  6. package/dist/client/index.js.map +1 -1
  7. package/dist/client/readContracts.d.ts +3 -0
  8. package/dist/client/readContracts.d.ts.map +1 -1
  9. package/dist/client/readContracts.js +4 -0
  10. package/dist/client/readContracts.js.map +1 -1
  11. package/dist/client/setup.test.d.ts +18 -0
  12. package/dist/client/setup.test.d.ts.map +1 -0
  13. package/dist/client/setup.test.js +33 -0
  14. package/dist/client/setup.test.js.map +1 -0
  15. package/dist/client/types/triggers.d.ts +1 -1
  16. package/dist/client/types/triggers.d.ts.map +1 -1
  17. package/dist/client/webhooks/helpers.d.ts.map +1 -1
  18. package/dist/client/webhooks/helpers.js +1 -0
  19. package/dist/client/webhooks/helpers.js.map +1 -1
  20. package/dist/client/webhooks/payoutReconciliation.d.ts +8 -0
  21. package/dist/client/webhooks/payoutReconciliation.d.ts.map +1 -0
  22. package/dist/client/webhooks/payoutReconciliation.js +36 -0
  23. package/dist/client/webhooks/payoutReconciliation.js.map +1 -0
  24. package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts +117 -0
  25. package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts.map +1 -0
  26. package/dist/client/webhooks/payoutReconciliationTestHelpers.js +105 -0
  27. package/dist/client/webhooks/payoutReconciliationTestHelpers.js.map +1 -0
  28. package/dist/client/webhooks/processors.d.ts.map +1 -1
  29. package/dist/client/webhooks/processors.js +1 -20
  30. package/dist/client/webhooks/processors.js.map +1 -1
  31. package/docs/read-contracts.md +21 -5
  32. package/package.json +1 -1
  33. package/src/client/index.ts +3 -6
  34. package/src/client/readContracts.ts +5 -0
  35. package/src/client/types/triggers.ts +1 -0
  36. package/src/client/webhooks/helpers.ts +1 -0
  37. package/src/client/webhooks/payout-canceled.integration.test.ts +14 -1
  38. package/src/client/webhooks/payout-reconciliation.integration.test.ts +168 -0
  39. package/src/client/webhooks/payoutReconciliation.ts +72 -0
  40. package/src/client/webhooks/payoutReconciliationTestHelpers.ts +135 -0
  41. package/src/client/webhooks/processors.test.ts +44 -29
  42. package/src/client/webhooks/processors.ts +1 -27
@@ -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
+ }
@@ -96,6 +96,7 @@ type MockReversal = {
96
96
 
97
97
  function makeStripe() {
98
98
  return {
99
+ payouts: { retrieve: vi.fn() },
99
100
  refunds: {
100
101
  list: vi.fn(() =>
101
102
  (async function* () {
@@ -791,63 +792,77 @@ describe("processEvent — payments (status collapse)", () => {
791
792
  });
792
793
 
793
794
  describe("processEvent — payouts", () => {
794
- it("scopes connected payouts to the event account instead of the external bank", async () => {
795
+ function context(id: string, status = "paid") {
795
796
  const whCtx = makeWhCtx();
796
- const payoutEvent = event("payout.paid", {
797
- id: "po_connected",
798
- destination: "ba_bank",
797
+ whCtx.ctx.runMutation.mockResolvedValue(1 as never);
798
+ whCtx.stripe.payouts.retrieve.mockResolvedValue({
799
+ id,
799
800
  amount: 5000,
800
801
  currency: "usd",
801
- status: "paid",
802
+ status,
802
803
  arrival_date: 1700000000,
803
- metadata: {},
804
+ automatic: true,
805
+ method: "standard",
806
+ metadata: { accountId: "acct_untrusted" },
807
+ });
808
+ return whCtx;
809
+ }
810
+ it("scopes connected provider reads to the signed event account", async () => {
811
+ const whCtx = context("po_connected");
812
+ const payoutEvent = event("payout.paid", {
813
+ id: "po_connected",
814
+ destination: "ba_bank",
815
+ metadata: { accountId: "acct_untrusted" },
804
816
  });
805
817
  payoutEvent.account = "acct_store";
806
818
  await processEvent(whCtx, payoutEvent);
807
- expect(dispatchedPayload(whCtx.ctx).data.accountId).toBe("acct_store");
819
+ expect(whCtx.stripe.payouts.retrieve).toHaveBeenCalledWith(
820
+ "po_connected",
821
+ {},
822
+ { stripeAccount: "acct_store" },
823
+ );
824
+ expect(dispatchedPayload(whCtx.ctx, 1).data).toMatchObject({
825
+ accountId: "acct_store",
826
+ generation: 1,
827
+ payout: { status: "paid", accountId: "acct_store" },
828
+ });
808
829
  });
809
-
810
- it("normalizes a payout, taking destination as a string id", async () => {
811
- const whCtx = makeWhCtx();
830
+ it("uses explicit platform scope instead of a bank destination or metadata account", async () => {
831
+ const whCtx = context("po_1");
812
832
  await processEvent(
813
833
  whCtx,
814
834
  event("payout.paid", {
815
835
  id: "po_1",
816
836
  destination: "ba_123",
817
- amount: 5000,
818
- currency: "usd",
819
- status: "paid",
820
- arrival_date: 1700000000,
821
- method: "standard",
822
- metadata: {},
837
+ status: "pending",
838
+ metadata: { accountId: "acct_forged" },
823
839
  }),
824
840
  );
825
- const { path, data } = dispatchedPayload(whCtx.ctx);
826
- expect(path).toBe("betterStripe/connect/mutations/upsertPayout");
841
+ const { path, data } = dispatchedPayload(whCtx.ctx, 1);
842
+ expect(path).toBe("betterStripe/payoutSync/mutations/finish");
827
843
  expect(data).toMatchObject({
828
844
  stripePayoutId: "po_1",
829
- accountId: "ba_123",
830
- amount: 5000,
831
- status: "paid",
832
- method: "standard",
845
+ accountId: "platform",
846
+ generation: 1,
847
+ payout: { status: "paid", amount: 5000, accountId: "platform" },
833
848
  });
849
+ expect(whCtx.stripe.payouts.retrieve).toHaveBeenCalledWith("po_1", {}, {});
834
850
  });
835
-
836
- it("falls back to an empty-string accountId for an expanded destination without id", async () => {
837
- const whCtx = makeWhCtx();
851
+ it("ignores malformed stale event fields when the current platform payout is valid", async () => {
852
+ const whCtx = context("po_2", "failed");
838
853
  await processEvent(
839
854
  whCtx,
840
855
  event("payout.failed", {
841
856
  id: "po_2",
842
857
  destination: {},
843
- amount: 100,
844
- currency: "usd",
845
- status: "failed",
846
858
  arrival_date: null,
847
859
  metadata: {},
848
860
  }),
849
861
  );
850
- expect(dispatchedPayload(whCtx.ctx).data.accountId).toBe("");
862
+ expect(dispatchedPayload(whCtx.ctx, 1).data).toMatchObject({
863
+ accountId: "platform",
864
+ payout: { status: "failed", arrivalDate: "2023-11-14T22:13:20.000Z" },
865
+ });
851
866
  });
852
867
  });
853
868
 
@@ -1,3 +1,4 @@
1
+ import { handlePayoutEvent } from "./payoutReconciliation.js";
1
2
  import { reconcileDisputeMoney } from "./disputeRecovery.js";
2
3
  import { successfulRefundPrincipal } from "../connect/refundPrincipal.js";
3
4
  import { subscriptionRouting } from "../billing/subscriptionRouting.js";
@@ -900,33 +901,6 @@ async function upsertPaymentFromStripe(
900
901
  });
901
902
  }
902
903
 
903
- async function handlePayoutEvent(
904
- whCtx: WebhookContext,
905
- payout: Stripe.Payout,
906
- connectedAccountId?: string,
907
- ): Promise<void> {
908
- await dispatchUpsert(whCtx, "payoutUpserted", {
909
- stripePayoutId: payout.id,
910
- accountId:
911
- connectedAccountId ??
912
- (payout.metadata?.accountId?.startsWith("acct_")
913
- ? payout.metadata.accountId
914
- : undefined) ??
915
- (typeof payout.destination === "string"
916
- ? payout.destination
917
- : (payout.destination?.id ?? "")),
918
- amount: payout.amount,
919
- currency: payout.currency,
920
- status: payout.status as
921
- "pending" | "paid" | "failed" | "canceled" | "in_transit",
922
- arrivalDate: epochToIso(payout.arrival_date),
923
- method: payout.method ?? undefined,
924
- failureCode: payout.failure_code ?? undefined,
925
- failureMessage: payout.failure_message ?? undefined,
926
- metadata: payout.metadata ?? undefined,
927
- });
928
- }
929
-
930
904
  // Stripe types Refund.status as `string | null`; clamp to the documented union.
931
905
  const REFUND_STATUSES = new Set([
932
906
  "pending",