@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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/client/index.d.ts +0 -3
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +2 -4
- package/dist/client/index.js.map +1 -1
- package/dist/client/readContracts.d.ts +3 -0
- package/dist/client/readContracts.d.ts.map +1 -1
- package/dist/client/readContracts.js +4 -0
- package/dist/client/readContracts.js.map +1 -1
- package/dist/client/setup.test.d.ts +18 -0
- package/dist/client/setup.test.d.ts.map +1 -0
- package/dist/client/setup.test.js +33 -0
- package/dist/client/setup.test.js.map +1 -0
- package/dist/client/types/triggers.d.ts +1 -1
- package/dist/client/types/triggers.d.ts.map +1 -1
- package/dist/client/webhooks/helpers.d.ts.map +1 -1
- package/dist/client/webhooks/helpers.js +1 -0
- package/dist/client/webhooks/helpers.js.map +1 -1
- package/dist/client/webhooks/payoutReconciliation.d.ts +8 -0
- package/dist/client/webhooks/payoutReconciliation.d.ts.map +1 -0
- package/dist/client/webhooks/payoutReconciliation.js +36 -0
- package/dist/client/webhooks/payoutReconciliation.js.map +1 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts +117 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts.map +1 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.js +105 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.js.map +1 -0
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -20
- package/dist/client/webhooks/processors.js.map +1 -1
- package/docs/read-contracts.md +21 -5
- package/package.json +1 -1
- package/src/client/index.ts +3 -6
- package/src/client/readContracts.ts +5 -0
- package/src/client/types/triggers.ts +1 -0
- package/src/client/webhooks/helpers.ts +1 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +14 -1
- package/src/client/webhooks/payout-reconciliation.integration.test.ts +168 -0
- package/src/client/webhooks/payoutReconciliation.ts +72 -0
- package/src/client/webhooks/payoutReconciliationTestHelpers.ts +135 -0
- package/src/client/webhooks/processors.test.ts +44 -29
- 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
|
-
|
|
795
|
+
function context(id: string, status = "paid") {
|
|
795
796
|
const whCtx = makeWhCtx();
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
|
802
|
+
status,
|
|
802
803
|
arrival_date: 1700000000,
|
|
803
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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/
|
|
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: "
|
|
830
|
-
|
|
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
|
-
|
|
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
|
|
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",
|