@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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION.md +11 -0
- package/README.md +4 -1
- package/dist/client/connect/payoutExecution.d.ts.map +1 -1
- package/dist/client/connect/payoutExecution.js +8 -83
- package/dist/client/connect/payoutExecution.js.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +2 -10
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
- package/dist/client/connect/payoutPreview.d.ts +20 -0
- package/dist/client/connect/payoutPreview.d.ts.map +1 -0
- package/dist/client/connect/payoutPreview.js +44 -0
- package/dist/client/connect/payoutPreview.js.map +1 -0
- package/dist/client/connect/payoutQualification.d.ts +30 -0
- package/dist/client/connect/payoutQualification.d.ts.map +1 -0
- package/dist/client/connect/payoutQualification.js +98 -0
- package/dist/client/connect/payoutQualification.js.map +1 -0
- 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/payoutPolicy.d.ts +3 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +4 -0
- package/dist/client/payoutPolicy.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/dist/component/_generated/component.d.ts +39 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/affiliateRecovery/accounting.d.ts +2 -2
- package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -1
- package/dist/component/affiliateRecovery/accounting.js.map +1 -1
- package/dist/component/payoutRelease/helpers.d.ts +4 -4
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.js.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +2 -29
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/planning.d.ts +27 -0
- package/dist/component/payoutRelease/planning.d.ts.map +1 -0
- package/dist/component/payoutRelease/planning.js +35 -0
- package/dist/component/payoutRelease/planning.js.map +1 -0
- package/dist/component/payoutRelease/preview.d.ts +117 -0
- package/dist/component/payoutRelease/preview.d.ts.map +1 -0
- package/dist/component/payoutRelease/preview.js +82 -0
- package/dist/component/payoutRelease/preview.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +39 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +6 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/docs/read-contracts.md +21 -5
- package/docs/settlement.md +57 -0
- package/package.json +1 -1
- package/src/client/connect/payoutExecution.ts +12 -99
- package/src/client/connect/payoutExecutionTestHelpers.ts +15 -13
- package/src/client/connect/payoutPreview.test.ts +269 -0
- package/src/client/connect/payoutPreview.ts +77 -0
- package/src/client/connect/payoutQualification.ts +145 -0
- package/src/client/index.ts +3 -6
- package/src/client/payoutPolicy.ts +14 -0
- 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
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +42 -0
- package/src/component/affiliateRecovery/accounting.ts +2 -2
- package/src/component/payoutRelease/helpers.ts +7 -4
- package/src/component/payoutRelease/mutations.ts +2 -37
- package/src/component/payoutRelease/planning.ts +55 -0
- package/src/component/payoutRelease/preview.ts +118 -0
- 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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|