@fantastic.dev/better-stripe 0.4.0 → 0.6.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 +61 -0
- package/MIGRATION.md +135 -0
- package/README.md +23 -2
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +22 -8
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/subscriptionRouting.d.ts +24 -0
- package/dist/client/billing/subscriptionRouting.d.ts.map +1 -0
- package/dist/client/billing/subscriptionRouting.js +19 -0
- package/dist/client/billing/subscriptionRouting.js.map +1 -0
- package/dist/client/billing/subscriptions.d.ts +6 -6
- package/dist/client/billing/subscriptions.d.ts.map +1 -1
- package/dist/client/billing/subscriptions.js +31 -12
- package/dist/client/billing/subscriptions.js.map +1 -1
- package/dist/client/connect/disputes.js.map +1 -1
- package/dist/client/constants.js +1 -1
- package/dist/client/core/accountLinks.d.ts +1 -0
- package/dist/client/core/accountLinks.d.ts.map +1 -1
- package/dist/client/core/accountLinks.js +0 -6
- package/dist/client/core/accountLinks.js.map +1 -1
- package/dist/client/core/accountStatus.d.ts.map +1 -1
- package/dist/client/core/accountStatus.js +23 -8
- package/dist/client/core/accountStatus.js.map +1 -1
- package/dist/client/index.d.ts +5 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/types/documents.d.ts +1 -0
- package/dist/client/types/documents.d.ts.map +1 -1
- package/dist/client/types/events.d.ts +2 -1
- package/dist/client/types/events.d.ts.map +1 -1
- package/dist/client/types/options.d.ts +7 -0
- package/dist/client/types/options.d.ts.map +1 -1
- package/dist/client/utils/stripeDashboardUrl.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/webhooks/handler.d.ts.map +1 -1
- package/dist/client/webhooks/handler.js +35 -1
- package/dist/client/webhooks/handler.js.map +1 -1
- package/dist/client/webhooks/hooks.d.ts +1 -1
- package/dist/client/webhooks/hooks.d.ts.map +1 -1
- package/dist/client/webhooks/hooks.js +5 -1
- package/dist/client/webhooks/hooks.js.map +1 -1
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +12 -2
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/client/webhooks/v2.d.ts.map +1 -1
- package/dist/client/webhooks/v2.js +11 -3
- package/dist/client/webhooks/v2.js.map +1 -1
- package/dist/component/_generated/component.d.ts +8 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +7 -1
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js +2 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/billing/mutations.d.ts +29 -28
- package/dist/component/billing/mutations.d.ts.map +1 -1
- package/dist/component/billing/mutations.js +1 -0
- package/dist/component/billing/mutations.js.map +1 -1
- package/dist/component/billing/queries.d.ts +138 -131
- package/dist/component/billing/queries.d.ts.map +1 -1
- package/dist/component/billing/schema.d.ts +67 -65
- package/dist/component/billing/schema.d.ts.map +1 -1
- package/dist/component/billing/validators.d.ts +94 -91
- package/dist/component/billing/validators.d.ts.map +1 -1
- package/dist/component/billing/validators.js +1 -0
- package/dist/component/billing/validators.js.map +1 -1
- package/dist/component/connect/mutations.d.ts +37 -37
- package/dist/component/connect/queries.d.ts +115 -115
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +34 -71
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/connect/schema.d.ts +73 -73
- package/dist/component/connect/validators.d.ts +119 -119
- package/dist/component/connect/validators.d.ts.map +1 -1
- package/dist/component/convex.config.d.ts +1 -0
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +2 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/core/mutations.d.ts +12 -12
- package/dist/component/core/queries.d.ts +34 -34
- package/dist/component/core/schema.d.ts +11 -11
- package/dist/component/core/validators.d.ts +14 -14
- package/dist/component/core/validators.d.ts.map +1 -1
- package/dist/component/lib/fees.d.ts +12 -12
- package/dist/component/lib/fees.d.ts.map +1 -1
- package/dist/component/lib/fees.js +1 -1
- package/dist/component/lib/reversals.js.map +1 -1
- package/dist/component/products/mutations.d.ts +7 -7
- package/dist/component/products/queries.d.ts +33 -33
- package/dist/component/products/schema.d.ts +15 -15
- package/dist/component/products/validators.d.ts +16 -16
- package/dist/component/products/validators.d.ts.map +1 -1
- package/dist/component/schema.d.ts +173 -171
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/webhooks/mutations.d.ts +3 -3
- package/dist/component/webhooks/queries.d.ts +12 -12
- package/dist/component/webhooks/schema.d.ts +7 -7
- package/dist/component/webhooks/validators.d.ts +10 -10
- package/dist/component/webhooks/validators.d.ts.map +1 -1
- package/dist/react/components/AccountCreateCard.js.map +1 -1
- package/dist/react/components/BuyerBillingView.js +1 -1
- package/dist/react/components/CheckoutSessionProviderWithKey.d.ts +1 -1
- package/dist/react/components/DisputeDetail.js +1 -1
- package/dist/react/components/DisputesList.js +1 -1
- package/dist/react/components/EarningsSummary.js +1 -1
- package/dist/react/components/EmbeddedCheckout.d.ts.map +1 -1
- package/dist/react/components/EmbeddedCheckout.js +1 -1
- package/dist/react/components/EmbeddedCheckout.js.map +1 -1
- package/dist/react/components/PayoutSchedule.js +1 -1
- package/dist/react/components/SplitBreakdown.js +1 -1
- package/dist/react/components/StripeProvider.d.ts +1 -1
- package/dist/react/components/StripeProviderWithKey.d.ts +1 -1
- package/dist/react/hooks/useAccount.d.ts.map +1 -1
- package/dist/react/hooks/useAccountOnboarding.d.ts.map +1 -1
- package/dist/react/hooks/useCheckout.d.ts.map +1 -1
- package/dist/react/hooks/useDisputeWithCountdown.d.ts.map +1 -1
- package/dist/react/hooks/useDisputes.d.ts.map +1 -1
- package/dist/react/hooks/useEarnings.d.ts.map +1 -1
- package/dist/react/hooks/useInvoices.d.ts.map +1 -1
- package/dist/react/hooks/usePaymentMethodActions.js.map +1 -1
- package/dist/react/hooks/usePaymentMethods.d.ts.map +1 -1
- package/dist/react/hooks/usePrices.d.ts.map +1 -1
- package/dist/react/hooks/useProducts.d.ts.map +1 -1
- package/dist/react/hooks/useSplitBreakdown.d.ts.map +1 -1
- package/dist/react/hooks/useSubscription.d.ts.map +1 -1
- package/dist/react/hooks/useSubscriptions.d.ts.map +1 -1
- package/dist/react/lib/stripe-element-styles.d.ts.map +1 -1
- package/dist/react/lib/stripe-element-styles.js.map +1 -1
- package/package.json +25 -23
- package/src/client/billing/checkout.test.ts +62 -14
- package/src/client/billing/checkout.ts +26 -10
- package/src/client/billing/subscriptionRouting.ts +25 -0
- package/src/client/billing/subscriptions.test.ts +90 -28
- package/src/client/billing/subscriptions.ts +53 -18
- package/src/client/constants.ts +1 -1
- package/src/client/core/accountLinks.test.ts +20 -13
- package/src/client/core/accountLinks.ts +2 -10
- package/src/client/core/accountStatus.test.ts +67 -0
- package/src/client/core/accountStatus.ts +30 -13
- package/src/client/helpers.test.ts +2 -2
- package/src/client/index.ts +49 -44
- package/src/client/stripe-version.test.ts +47 -0
- package/src/client/types/documents.ts +1 -0
- package/src/client/types/events.ts +2 -1
- package/src/client/types/options.ts +7 -0
- package/src/client/webhooks/handler.ts +38 -1
- package/src/client/webhooks/hooks.ts +3 -0
- package/src/client/webhooks/processors.test.ts +201 -40
- package/src/client/webhooks/processors.ts +21 -17
- package/src/client/webhooks/retry.integration.test.ts +196 -0
- package/src/client/webhooks/v2.test.ts +36 -0
- package/src/client/webhooks/v2.ts +14 -4
- package/src/component/_generated/component.ts +15 -28
- package/src/component/_generated/server.ts +9 -2
- package/src/component/billing/mutations.ts +1 -0
- package/src/component/billing/validators.ts +1 -0
- package/src/component/config-queries.test.ts +8 -4
- package/src/component/connect/queries.ts +31 -66
- package/src/component/convex.config.ts +2 -1
- package/src/component/earnings-totals.test.ts +27 -1
- package/src/component/lib/fees.ts +1 -1
- package/src/react/components/EmbeddedCheckout.render.test.tsx +57 -0
- package/src/react/components/EmbeddedCheckout.tsx +1 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { getFunctionAddress } from "convex/server";
|
|
3
|
+
import Stripe from "stripe";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import schema from "../../component/schema.js";
|
|
7
|
+
import { components, initConvexTest } from "../setup.test.js";
|
|
8
|
+
import type { RegisterRoutesConfig } from "../types.js";
|
|
9
|
+
import { handleWebhookRequest } from "./handler.js";
|
|
10
|
+
|
|
11
|
+
const signingSecret = "whsec_retry_regression";
|
|
12
|
+
const modules = import.meta.glob("../../component/**/*.{ts,js,tsx,jsx}");
|
|
13
|
+
|
|
14
|
+
function fixture() {
|
|
15
|
+
const t = initConvexTest();
|
|
16
|
+
t.registerComponent("betterStripe", schema, modules);
|
|
17
|
+
let attempts = 0;
|
|
18
|
+
let unavailable = "";
|
|
19
|
+
const event = {
|
|
20
|
+
id: "evt_checkout_retry",
|
|
21
|
+
object: "event",
|
|
22
|
+
api_version: "2026-09-30.endive",
|
|
23
|
+
type: "checkout.session.completed",
|
|
24
|
+
livemode: false,
|
|
25
|
+
data: {
|
|
26
|
+
object: {
|
|
27
|
+
id: "cs_retry",
|
|
28
|
+
mode: "payment",
|
|
29
|
+
status: "complete",
|
|
30
|
+
payment_status: "paid",
|
|
31
|
+
metadata: { userId: "buyer", purchaseType: "communityMembership" },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const fulfill = async () => {
|
|
36
|
+
attempts++;
|
|
37
|
+
if (attempts === 1) throw new Error("Transient fulfillment failure");
|
|
38
|
+
};
|
|
39
|
+
const config: RegisterRoutesConfig = {
|
|
40
|
+
stripeSecretKey: "sk_test_local_signature_only",
|
|
41
|
+
webhookSecret: signingSecret,
|
|
42
|
+
retryOnHookError: (verified) => verified.type === event.type,
|
|
43
|
+
events: { [event.type]: fulfill },
|
|
44
|
+
};
|
|
45
|
+
const deliver = async (tamper = false) => {
|
|
46
|
+
const body = JSON.stringify(event);
|
|
47
|
+
const signature = await Stripe.webhooks.generateTestHeaderStringAsync({
|
|
48
|
+
payload: body,
|
|
49
|
+
secret: signingSecret,
|
|
50
|
+
});
|
|
51
|
+
return t.action(async (ctx) => {
|
|
52
|
+
const runMutation: typeof ctx.runMutation = async (ref, args) => {
|
|
53
|
+
if (
|
|
54
|
+
unavailable &&
|
|
55
|
+
getFunctionAddress(ref).reference?.endsWith(`/${unavailable}`)
|
|
56
|
+
) {
|
|
57
|
+
throw new Error("Ledger unavailable");
|
|
58
|
+
}
|
|
59
|
+
return ctx.runMutation(ref, args);
|
|
60
|
+
};
|
|
61
|
+
const response = await handleWebhookRequest(
|
|
62
|
+
{ ...ctx, runMutation },
|
|
63
|
+
new Request("https://example.test/stripe/webhook", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
body: tamper ? body.replace("cs_retry", "cs_tampered") : body,
|
|
66
|
+
headers: { "stripe-signature": signature },
|
|
67
|
+
}),
|
|
68
|
+
components.betterStripe,
|
|
69
|
+
config,
|
|
70
|
+
);
|
|
71
|
+
return { status: response.status, body: await response.json() };
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
const ledger = () =>
|
|
75
|
+
t.query(components.betterStripe.webhooks.queries.getWebhookEvent, {
|
|
76
|
+
stripeEventId: event.id,
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
t,
|
|
80
|
+
event,
|
|
81
|
+
config,
|
|
82
|
+
fulfill,
|
|
83
|
+
deliver,
|
|
84
|
+
ledger,
|
|
85
|
+
attempts: () => attempts,
|
|
86
|
+
fail: (operation: string) => {
|
|
87
|
+
unavailable = operation;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
afterEach(() => vi.useRealTimers());
|
|
93
|
+
|
|
94
|
+
describe("required V1 hooks with the real SDK signature verifier and component ledger", () => {
|
|
95
|
+
it("persists checkout before fulfillment, retries a failed hook, then deduplicates", async () => {
|
|
96
|
+
const f = fixture();
|
|
97
|
+
expect((await f.deliver()).status).toBe(500);
|
|
98
|
+
expect(await f.ledger()).toMatchObject({
|
|
99
|
+
status: "failed",
|
|
100
|
+
lastError: "Transient fulfillment failure",
|
|
101
|
+
});
|
|
102
|
+
expect(
|
|
103
|
+
await f.t.query(
|
|
104
|
+
components.betterStripe.billing.queries.getCheckoutSessionByStripeId,
|
|
105
|
+
{ stripeSessionId: "cs_retry" },
|
|
106
|
+
),
|
|
107
|
+
).toMatchObject({ status: "complete" });
|
|
108
|
+
expect((await f.deliver()).status).toBe(200);
|
|
109
|
+
expect(await f.ledger()).toMatchObject({ status: "processed" });
|
|
110
|
+
expect(await f.deliver()).toMatchObject({
|
|
111
|
+
status: 200,
|
|
112
|
+
body: { deduplicated: true },
|
|
113
|
+
});
|
|
114
|
+
expect(f.attempts()).toBe(2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("requires onEvent success as well as the event-specific hook", async () => {
|
|
118
|
+
const f = fixture();
|
|
119
|
+
f.config.onEvent = f.fulfill;
|
|
120
|
+
f.config.events = { [f.event.type]: vi.fn().mockResolvedValue(undefined) };
|
|
121
|
+
expect((await f.deliver()).status).toBe(500);
|
|
122
|
+
expect(f.config.events[f.event.type]).not.toHaveBeenCalled();
|
|
123
|
+
expect((await f.deliver()).status).toBe(200);
|
|
124
|
+
expect(f.config.events[f.event.type]).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("verifies the signature before running the selector or touching the ledger", async () => {
|
|
128
|
+
const f = fixture();
|
|
129
|
+
f.config.retryOnHookError = vi.fn(() => true);
|
|
130
|
+
expect((await f.deliver(true)).status).toBe(400);
|
|
131
|
+
expect(f.config.retryOnHookError).not.toHaveBeenCalled();
|
|
132
|
+
expect(f.attempts()).toBe(0);
|
|
133
|
+
expect(await f.ledger()).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it.each([undefined, () => false])(
|
|
137
|
+
"preserves best-effort hooks when the selector is %s",
|
|
138
|
+
async (selector) => {
|
|
139
|
+
const f = fixture();
|
|
140
|
+
f.config.retryOnHookError = selector;
|
|
141
|
+
expect((await f.deliver()).status).toBe(200);
|
|
142
|
+
expect(await f.ledger()).toMatchObject({ status: "processed" });
|
|
143
|
+
await f.deliver();
|
|
144
|
+
expect(f.attempts()).toBe(1);
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
it("returns 503 for an in-flight delivery without concurrent fulfillment", async () => {
|
|
149
|
+
const f = fixture();
|
|
150
|
+
await f.t.mutation(
|
|
151
|
+
components.betterStripe.webhooks.mutations.insertWebhookEvent,
|
|
152
|
+
{
|
|
153
|
+
stripeEventId: f.event.id,
|
|
154
|
+
eventType: f.event.type,
|
|
155
|
+
livemode: false,
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
expect((await f.deliver()).status).toBe(503);
|
|
159
|
+
expect(f.attempts()).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("fails closed if reservation is unavailable", async () => {
|
|
163
|
+
const f = fixture();
|
|
164
|
+
f.fail("insertWebhookEvent");
|
|
165
|
+
expect((await f.deliver()).status).toBe(500);
|
|
166
|
+
expect(await f.ledger()).toBeNull();
|
|
167
|
+
expect(f.attempts()).toBe(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it.each(["markWebhookEventProcessed", "markWebhookEventFailed"])(
|
|
171
|
+
"recovers a stale reservation after %s fails",
|
|
172
|
+
async (operation) => {
|
|
173
|
+
const f = fixture();
|
|
174
|
+
if (operation === "markWebhookEventProcessed") await f.deliver();
|
|
175
|
+
f.fail(operation);
|
|
176
|
+
expect((await f.deliver()).status).toBe(500);
|
|
177
|
+
expect(await f.ledger()).toMatchObject({ status: "processing" });
|
|
178
|
+
f.fail("");
|
|
179
|
+
expect((await f.deliver()).status).toBe(503);
|
|
180
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
181
|
+
vi.setSystemTime(Date.now() + 11 * 60 * 1000);
|
|
182
|
+
expect((await f.deliver()).status).toBe(200);
|
|
183
|
+
expect(await f.ledger()).toMatchObject({ status: "processed" });
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
it("returns a retryable response when the selection policy throws", async () => {
|
|
188
|
+
const f = fixture();
|
|
189
|
+
f.config.retryOnHookError = () => {
|
|
190
|
+
throw new Error("Policy unavailable");
|
|
191
|
+
};
|
|
192
|
+
expect((await f.deliver()).status).toBe(500);
|
|
193
|
+
expect(await f.ledger()).toBeNull();
|
|
194
|
+
expect(f.attempts()).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -155,6 +155,42 @@ describe("verifyV2Event", () => {
|
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
describe("handleV2Event — routing / early returns", () => {
|
|
158
|
+
it("resolves an account pointer without id from its URL", async () => {
|
|
159
|
+
const whCtx = makeWhCtx();
|
|
160
|
+
whCtx.stripe.v2.core.accounts.retrieve.mockResolvedValue(makeAccount());
|
|
161
|
+
expect(
|
|
162
|
+
await handleV2Event(
|
|
163
|
+
whCtx,
|
|
164
|
+
makeThinEvent({
|
|
165
|
+
related_object: {
|
|
166
|
+
type: "v2.core.account",
|
|
167
|
+
url: "/v2/core/accounts/acct_123",
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
).toBe("acct_123");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("preserves the verified notification context when refreshing account capabilities", async () => {
|
|
175
|
+
const whCtx = makeWhCtx();
|
|
176
|
+
whCtx.stripe.v2.core.accounts.retrieve.mockResolvedValue(
|
|
177
|
+
makeAccount({
|
|
178
|
+
configuration: {
|
|
179
|
+
merchant: {
|
|
180
|
+
applied: true,
|
|
181
|
+
capabilities: { card_payments: { status: "rejected" } },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
await handleV2Event(whCtx, makeThinEvent({ context: "org_123/acct_123" }));
|
|
187
|
+
expect(whCtx.stripe.v2.core.accounts.retrieve).toHaveBeenCalledWith(
|
|
188
|
+
"acct_123",
|
|
189
|
+
expect.objectContaining({ include: expect.any(Array) }),
|
|
190
|
+
{ stripeContext: "org_123/acct_123" },
|
|
191
|
+
);
|
|
192
|
+
expect(dispatched(whCtx.ctx).data.onboardingStatus).toBe("restricted");
|
|
193
|
+
});
|
|
158
194
|
it("skips and returns null for a non-account V2 event type", async () => {
|
|
159
195
|
const info = vi.spyOn(console, "info").mockImplementation(() => {});
|
|
160
196
|
const whCtx = makeWhCtx();
|
|
@@ -58,7 +58,7 @@ export async function handleV2Event(
|
|
|
58
58
|
// (type: 'v2.core.account') or a sub-resource (type: 'v2.core.account_person').
|
|
59
59
|
// For sub-resources, parse the account ID from the URL (/v2/core/accounts/acct_xxx/...).
|
|
60
60
|
let accountId: string;
|
|
61
|
-
if (relatedObject.type === "v2.core.account") {
|
|
61
|
+
if (relatedObject.type === "v2.core.account" && relatedObject.id) {
|
|
62
62
|
accountId = relatedObject.id;
|
|
63
63
|
} else if (relatedObject.url) {
|
|
64
64
|
const match = relatedObject.url.match(/\/accounts\/(acct_[a-zA-Z0-9]+)/);
|
|
@@ -74,15 +74,25 @@ export async function handleV2Event(
|
|
|
74
74
|
return null;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
const
|
|
77
|
+
const retrieveParams = {
|
|
78
78
|
include: [
|
|
79
79
|
"configuration.merchant",
|
|
80
80
|
"configuration.recipient",
|
|
81
81
|
"configuration.customer",
|
|
82
82
|
"identity",
|
|
83
83
|
"requirements",
|
|
84
|
-
]
|
|
85
|
-
|
|
84
|
+
] satisfies NonNullable<
|
|
85
|
+
Parameters<Stripe["v2"]["core"]["accounts"]["retrieve"]>[1]
|
|
86
|
+
>["include"],
|
|
87
|
+
};
|
|
88
|
+
// Preserve the verified notification's account/organization context when
|
|
89
|
+
// fetching the latest resource; otherwise organization keys can fetch from
|
|
90
|
+
// the wrong account. Keep the raw string in the public thin-event payload.
|
|
91
|
+
const account = thinEvent.context
|
|
92
|
+
? await stripe.v2.core.accounts.retrieve(accountId, retrieveParams, {
|
|
93
|
+
stripeContext: thinEvent.context,
|
|
94
|
+
})
|
|
95
|
+
: await stripe.v2.core.accounts.retrieve(accountId, retrieveParams);
|
|
86
96
|
const identity = account.identity;
|
|
87
97
|
const metadata = (account.metadata ?? {}) as Record<string, string>;
|
|
88
98
|
const { userId, orgId } = extractIdentifiers(metadata);
|
|
@@ -108,6 +108,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
108
108
|
destinationAccountId?: string;
|
|
109
109
|
feeCollectedAmount?: number;
|
|
110
110
|
feeRefundedAmount?: number;
|
|
111
|
+
isCollectionPaused?: boolean;
|
|
111
112
|
isTrialing: boolean;
|
|
112
113
|
metadata?: any;
|
|
113
114
|
orgId?: string;
|
|
@@ -157,6 +158,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
157
158
|
destinationAccountId?: string;
|
|
158
159
|
feeCollectedAmount?: number;
|
|
159
160
|
feeRefundedAmount?: number;
|
|
161
|
+
isCollectionPaused?: boolean;
|
|
160
162
|
isTrialing: boolean;
|
|
161
163
|
metadata?: any;
|
|
162
164
|
orgId?: string;
|
|
@@ -307,6 +309,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
307
309
|
destinationAccountId?: string;
|
|
308
310
|
feeCollectedAmount?: number;
|
|
309
311
|
feeRefundedAmount?: number;
|
|
312
|
+
isCollectionPaused?: boolean;
|
|
310
313
|
isTrialing: boolean;
|
|
311
314
|
metadata?: any;
|
|
312
315
|
orgId?: string;
|
|
@@ -353,6 +356,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
353
356
|
destinationAccountId?: string;
|
|
354
357
|
feeCollectedAmount?: number;
|
|
355
358
|
feeRefundedAmount?: number;
|
|
359
|
+
isCollectionPaused?: boolean;
|
|
356
360
|
isTrialing: boolean;
|
|
357
361
|
metadata?: any;
|
|
358
362
|
orgId?: string;
|
|
@@ -513,6 +517,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
513
517
|
destinationAccountId?: string;
|
|
514
518
|
feeCollectedAmount?: number;
|
|
515
519
|
feeRefundedAmount?: number;
|
|
520
|
+
isCollectionPaused?: boolean;
|
|
516
521
|
isTrialing: boolean;
|
|
517
522
|
metadata?: any;
|
|
518
523
|
orgId?: string;
|
|
@@ -559,6 +564,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
559
564
|
destinationAccountId?: string;
|
|
560
565
|
feeCollectedAmount?: number;
|
|
561
566
|
feeRefundedAmount?: number;
|
|
567
|
+
isCollectionPaused?: boolean;
|
|
562
568
|
isTrialing: boolean;
|
|
563
569
|
metadata?: any;
|
|
564
570
|
orgId?: string;
|
|
@@ -605,6 +611,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
605
611
|
destinationAccountId?: string;
|
|
606
612
|
feeCollectedAmount?: number;
|
|
607
613
|
feeRefundedAmount?: number;
|
|
614
|
+
isCollectionPaused?: boolean;
|
|
608
615
|
isTrialing: boolean;
|
|
609
616
|
metadata?: any;
|
|
610
617
|
orgId?: string;
|
|
@@ -663,6 +670,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
663
670
|
destinationAccountId?: string;
|
|
664
671
|
feeCollectedAmount?: number;
|
|
665
672
|
feeRefundedAmount?: number;
|
|
673
|
+
isCollectionPaused?: boolean;
|
|
666
674
|
isTrialing: boolean;
|
|
667
675
|
metadata?: any;
|
|
668
676
|
orgId?: string;
|
|
@@ -1325,10 +1333,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1325
1333
|
missingRequirements?: Array<string>;
|
|
1326
1334
|
name?: string;
|
|
1327
1335
|
onboardingStatus?:
|
|
1328
|
-
| "
|
|
1329
|
-
| "in_progress"
|
|
1330
|
-
| "complete"
|
|
1331
|
-
| "restricted";
|
|
1336
|
+
"pending" | "in_progress" | "complete" | "restricted";
|
|
1332
1337
|
orgId?: string;
|
|
1333
1338
|
requirements?: any;
|
|
1334
1339
|
stripeAccountId: string;
|
|
@@ -1352,10 +1357,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1352
1357
|
missingRequirements?: Array<string>;
|
|
1353
1358
|
name?: string;
|
|
1354
1359
|
onboardingStatus:
|
|
1355
|
-
| "
|
|
1356
|
-
| "in_progress"
|
|
1357
|
-
| "complete"
|
|
1358
|
-
| "restricted";
|
|
1360
|
+
"pending" | "in_progress" | "complete" | "restricted";
|
|
1359
1361
|
orgId?: string;
|
|
1360
1362
|
requirements?: any;
|
|
1361
1363
|
stripeAccountId: string;
|
|
@@ -1384,10 +1386,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1384
1386
|
missingRequirements?: Array<string>;
|
|
1385
1387
|
name?: string;
|
|
1386
1388
|
onboardingStatus:
|
|
1387
|
-
| "
|
|
1388
|
-
| "in_progress"
|
|
1389
|
-
| "complete"
|
|
1390
|
-
| "restricted";
|
|
1389
|
+
"pending" | "in_progress" | "complete" | "restricted";
|
|
1391
1390
|
orgId?: string;
|
|
1392
1391
|
requirements?: any;
|
|
1393
1392
|
statementDescriptor?: string;
|
|
@@ -1414,10 +1413,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1414
1413
|
missingRequirements?: Array<string>;
|
|
1415
1414
|
name?: string;
|
|
1416
1415
|
onboardingStatus:
|
|
1417
|
-
| "
|
|
1418
|
-
| "in_progress"
|
|
1419
|
-
| "complete"
|
|
1420
|
-
| "restricted";
|
|
1416
|
+
"pending" | "in_progress" | "complete" | "restricted";
|
|
1421
1417
|
orgId?: string;
|
|
1422
1418
|
requirements?: any;
|
|
1423
1419
|
statementDescriptor?: string;
|
|
@@ -1444,10 +1440,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1444
1440
|
missingRequirements?: Array<string>;
|
|
1445
1441
|
name?: string;
|
|
1446
1442
|
onboardingStatus:
|
|
1447
|
-
| "
|
|
1448
|
-
| "in_progress"
|
|
1449
|
-
| "complete"
|
|
1450
|
-
| "restricted";
|
|
1443
|
+
"pending" | "in_progress" | "complete" | "restricted";
|
|
1451
1444
|
orgId?: string;
|
|
1452
1445
|
requirements?: any;
|
|
1453
1446
|
statementDescriptor?: string;
|
|
@@ -1474,10 +1467,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1474
1467
|
missingRequirements?: Array<string>;
|
|
1475
1468
|
name?: string;
|
|
1476
1469
|
onboardingStatus:
|
|
1477
|
-
| "
|
|
1478
|
-
| "in_progress"
|
|
1479
|
-
| "complete"
|
|
1480
|
-
| "restricted";
|
|
1470
|
+
"pending" | "in_progress" | "complete" | "restricted";
|
|
1481
1471
|
orgId?: string;
|
|
1482
1472
|
requirements?: any;
|
|
1483
1473
|
statementDescriptor?: string;
|
|
@@ -1495,10 +1485,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1495
1485
|
isReady: boolean;
|
|
1496
1486
|
missingRequirements: Array<string>;
|
|
1497
1487
|
onboardingStatus:
|
|
1498
|
-
| "
|
|
1499
|
-
| "in_progress"
|
|
1500
|
-
| "complete"
|
|
1501
|
-
| "restricted";
|
|
1488
|
+
"pending" | "in_progress" | "complete" | "restricted";
|
|
1502
1489
|
} | null,
|
|
1503
1490
|
Name
|
|
1504
1491
|
>;
|
|
@@ -31,9 +31,15 @@ import {
|
|
|
31
31
|
import type { DataModel } from "./dataModel.js";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Typesafe environment variables
|
|
34
|
+
* Typesafe environment variables.
|
|
35
|
+
*
|
|
36
|
+
* This includes platform-provided env vars and any variables declared in
|
|
37
|
+
* `convex.config.ts`.
|
|
35
38
|
*/
|
|
36
39
|
type Env = {
|
|
40
|
+
readonly CONVEX_CLOUD_URL: string;
|
|
41
|
+
readonly CONVEX_SITE_URL: string;
|
|
42
|
+
readonly STRIPE_PUBLISHABLE_KEY: string | undefined;
|
|
37
43
|
readonly STRIPE_SECRET_KEY: string;
|
|
38
44
|
};
|
|
39
45
|
|
|
@@ -113,7 +119,8 @@ export const internalAction: ActionBuilder<DataModel, "internal"> =
|
|
|
113
119
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
|
114
120
|
*/
|
|
115
121
|
export const httpAction: HttpActionBuilder = httpActionGeneric;
|
|
116
|
-
export const env: Env =
|
|
122
|
+
export const env: Env = (globalThis as unknown as { process: { env: Env } })
|
|
123
|
+
.process.env;
|
|
117
124
|
|
|
118
125
|
/**
|
|
119
126
|
* A set of services for use within Convex query functions.
|
|
@@ -25,6 +25,7 @@ export const upsertSubscription = mutation({
|
|
|
25
25
|
currentPeriodStart: v.optional(v.string()),
|
|
26
26
|
currentPeriodEnd: v.optional(v.string()),
|
|
27
27
|
cancelAtPeriodEnd: v.boolean(),
|
|
28
|
+
isCollectionPaused: v.optional(v.boolean()),
|
|
28
29
|
canceledAt: v.optional(v.string()),
|
|
29
30
|
isTrialing: v.boolean(),
|
|
30
31
|
trialStart: v.optional(v.string()),
|
|
@@ -46,6 +46,7 @@ export const subscriptionFields = {
|
|
|
46
46
|
currentPeriodStart: v.optional(v.string()),
|
|
47
47
|
currentPeriodEnd: v.optional(v.string()),
|
|
48
48
|
cancelAtPeriodEnd: v.boolean(),
|
|
49
|
+
isCollectionPaused: v.optional(v.boolean()),
|
|
49
50
|
canceledAt: v.optional(v.string()),
|
|
50
51
|
isTrialing: v.boolean(),
|
|
51
52
|
trialStart: v.optional(v.string()),
|
|
@@ -7,10 +7,7 @@ import schema from "./schema.js";
|
|
|
7
7
|
|
|
8
8
|
const modules = import.meta.glob("./**/*.*s");
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
// declared (required) in `convex.config.ts`; `STRIPE_PUBLISHABLE_KEY` is
|
|
12
|
-
// intentionally NOT declared (it is a frontend-only value), so the component
|
|
13
|
-
// never receives it and `getPublishableKey` always resolves to null.
|
|
10
|
+
// Both keys are declared in the component environment and passed by the app.
|
|
14
11
|
describe("core/queries — Stripe config from declared env", () => {
|
|
15
12
|
const originalSecret = process.env.STRIPE_SECRET_KEY;
|
|
16
13
|
const originalPublishable = process.env.STRIPE_PUBLISHABLE_KEY;
|
|
@@ -44,4 +41,11 @@ describe("core/queries — Stripe config from declared env", () => {
|
|
|
44
41
|
const t = convexTest(schema, modules);
|
|
45
42
|
expect(await t.query(api.core.queries.getPublishableKey, {})).toBeNull();
|
|
46
43
|
});
|
|
44
|
+
it("getPublishableKey: returns the configured test publishable key", async () => {
|
|
45
|
+
process.env.STRIPE_PUBLISHABLE_KEY = "pk_test_abc123";
|
|
46
|
+
const t = convexTest(schema, modules);
|
|
47
|
+
expect(await t.query(api.core.queries.getPublishableKey, {})).toBe(
|
|
48
|
+
"pk_test_abc123",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
47
51
|
});
|
|
@@ -339,9 +339,6 @@ export const listTransfersByAccount = query({
|
|
|
339
339
|
// REVERSAL-CLAIM RECLAIM (BTS-74) — ops diagnostics for wedged legs
|
|
340
340
|
// =============================================================================
|
|
341
341
|
|
|
342
|
-
/** Page size for the wedged-claim scan. */
|
|
343
|
-
const RECLAIM_PAGE_SIZE = 200;
|
|
344
|
-
|
|
345
342
|
/**
|
|
346
343
|
* Fetch one reversal operation's claim record (BTS-63/74) by its id, or null.
|
|
347
344
|
* The `_creationTime` lets the reclaim path age-gate a release against Stripe's
|
|
@@ -367,7 +364,7 @@ export const getReversalOp = query({
|
|
|
367
364
|
* successor. For each wedged leg, returns the recorded ops holding an
|
|
368
365
|
* unexecuted slice on it (the blockers ops must reclaim).
|
|
369
366
|
*
|
|
370
|
-
* An admin/ops diagnostic, not a hot path. It
|
|
367
|
+
* An admin/ops diagnostic, not a hot path. It iterates the `transfers` and
|
|
371
368
|
* `transferReversalOps` tables to completion, so it is exact up to Convex's
|
|
372
369
|
* per-query document-read limit (16,384) — beyond which it throws (loud) rather
|
|
373
370
|
* than silently truncating, matching the BTS-64 aggregate convention.
|
|
@@ -378,18 +375,10 @@ export const listWedgedReversalClaims = query({
|
|
|
378
375
|
handler: async (ctx) => {
|
|
379
376
|
// Collect the wedged legs first.
|
|
380
377
|
const wedged: Doc<"transfers">[] = [];
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
.query("transfers")
|
|
385
|
-
.paginate({ cursor, numItems: RECLAIM_PAGE_SIZE });
|
|
386
|
-
for (const tr of page.page) {
|
|
387
|
-
if ((tr.reversalClaimedAmount ?? 0) > (tr.reversedAmount ?? 0)) {
|
|
388
|
-
wedged.push(tr);
|
|
389
|
-
}
|
|
378
|
+
for await (const tr of ctx.db.query("transfers")) {
|
|
379
|
+
if ((tr.reversalClaimedAmount ?? 0) > (tr.reversedAmount ?? 0)) {
|
|
380
|
+
wedged.push(tr);
|
|
390
381
|
}
|
|
391
|
-
if (page.isDone) break;
|
|
392
|
-
cursor = page.continueCursor;
|
|
393
382
|
}
|
|
394
383
|
if (wedged.length === 0) return [];
|
|
395
384
|
|
|
@@ -404,22 +393,14 @@ export const listWedgedReversalClaims = query({
|
|
|
404
393
|
string,
|
|
405
394
|
{ operationId: string; from: number; to: number }[]
|
|
406
395
|
>();
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
.
|
|
411
|
-
.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (!wedgedIds.has(s.stripeTransferId)) continue;
|
|
415
|
-
if (s.to <= (confirmedById.get(s.stripeTransferId) ?? 0)) continue;
|
|
416
|
-
const list = blockersByLeg.get(s.stripeTransferId) ?? [];
|
|
417
|
-
list.push({ operationId: op.operationId, from: s.from, to: s.to });
|
|
418
|
-
blockersByLeg.set(s.stripeTransferId, list);
|
|
419
|
-
}
|
|
396
|
+
for await (const op of ctx.db.query("transferReversalOps")) {
|
|
397
|
+
for (const s of op.slices) {
|
|
398
|
+
if (!wedgedIds.has(s.stripeTransferId)) continue;
|
|
399
|
+
if (s.to <= (confirmedById.get(s.stripeTransferId) ?? 0)) continue;
|
|
400
|
+
const list = blockersByLeg.get(s.stripeTransferId) ?? [];
|
|
401
|
+
list.push({ operationId: op.operationId, from: s.from, to: s.to });
|
|
402
|
+
blockersByLeg.set(s.stripeTransferId, list);
|
|
420
403
|
}
|
|
421
|
-
if (page.isDone) break;
|
|
422
|
-
cursor = page.continueCursor;
|
|
423
404
|
}
|
|
424
405
|
|
|
425
406
|
return wedged.map((t) => ({
|
|
@@ -441,20 +422,18 @@ export const listWedgedReversalClaims = query({
|
|
|
441
422
|
//
|
|
442
423
|
// The row-list queries above `.take(50)`, so summing their rows client-side
|
|
443
424
|
// (as the earnings hooks did) silently understated totals for accounts with
|
|
444
|
-
// more than 50 transfers/payouts. These aggregates
|
|
425
|
+
// more than 50 transfers/payouts. These aggregates iterate the account's
|
|
445
426
|
// ledger to completion and return EXACT totals regardless of row count, plus a
|
|
446
427
|
// bounded preview of rows for drill-down UIs. Exact up to Convex's per-query
|
|
447
|
-
// document
|
|
428
|
+
// document/byte read limits (currently 32,000 documents or 16 MiB; see
|
|
429
|
+
// https://docs.convex.dev/production/state/limits);
|
|
448
430
|
// beyond that the query throws (loud) rather than silently truncating —
|
|
449
431
|
// strictly safer than a fixed cap for financial figures.
|
|
450
432
|
|
|
451
|
-
/** Page size for the internal pagination loops. */
|
|
452
|
-
const EARNINGS_PAGE_SIZE = 200;
|
|
453
|
-
|
|
454
433
|
/**
|
|
455
434
|
* Exact earnings totals for one connected account (BTS-64): `gross` (sum of all
|
|
456
435
|
* transfer amounts, incl. reinstatements — the earnings view) and `reversed`
|
|
457
|
-
* (sum of all `reversedAmount`),
|
|
436
|
+
* (sum of all `reversedAmount`), iterated to completion so totals are never
|
|
458
437
|
* capped. `transfers` is a bounded preview (`previewLimit ?? 50` rows) for
|
|
459
438
|
* drill-down; use `gross`/`reversed`/`transferCount` for the money figures.
|
|
460
439
|
*/
|
|
@@ -476,22 +455,15 @@ export const getAccountEarnings = query({
|
|
|
476
455
|
let transferCount = 0;
|
|
477
456
|
const transfers: Doc<"transfers">[] = [];
|
|
478
457
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
.
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
gross += tr.amount;
|
|
489
|
-
reversed += tr.reversedAmount ?? 0;
|
|
490
|
-
transferCount += 1;
|
|
491
|
-
if (transfers.length < previewLimit) transfers.push(tr);
|
|
492
|
-
}
|
|
493
|
-
if (page.isDone) break;
|
|
494
|
-
cursor = page.continueCursor;
|
|
458
|
+
for await (const tr of ctx.db
|
|
459
|
+
.query("transfers")
|
|
460
|
+
.withIndex("by_destination_account_id", (q) =>
|
|
461
|
+
q.eq("destinationAccountId", args.destinationAccountId),
|
|
462
|
+
)) {
|
|
463
|
+
gross += tr.amount;
|
|
464
|
+
reversed += tr.reversedAmount ?? 0;
|
|
465
|
+
transferCount += 1;
|
|
466
|
+
if (transfers.length < previewLimit) transfers.push(tr);
|
|
495
467
|
}
|
|
496
468
|
|
|
497
469
|
return { gross, reversed, transferCount, transfers };
|
|
@@ -500,7 +472,7 @@ export const getAccountEarnings = query({
|
|
|
500
472
|
|
|
501
473
|
/**
|
|
502
474
|
* Exact payout totals for one connected account (BTS-64): `paidOut` (sum of all
|
|
503
|
-
* `paid` payout amounts),
|
|
475
|
+
* `paid` payout amounts), iterated to completion so it is never capped.
|
|
504
476
|
* `payouts` is a bounded preview (`previewLimit ?? 50` rows) for drill-down; use
|
|
505
477
|
* `paidOut`/`payoutCount` for the money figures.
|
|
506
478
|
*/
|
|
@@ -517,19 +489,12 @@ export const getAccountPayouts = query({
|
|
|
517
489
|
let payoutCount = 0;
|
|
518
490
|
const payouts: Doc<"payouts">[] = [];
|
|
519
491
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
for (const p of page.page) {
|
|
527
|
-
payoutCount += 1;
|
|
528
|
-
if (p.status === "paid") paidOut += p.amount;
|
|
529
|
-
if (payouts.length < previewLimit) payouts.push(p);
|
|
530
|
-
}
|
|
531
|
-
if (page.isDone) break;
|
|
532
|
-
cursor = page.continueCursor;
|
|
492
|
+
for await (const p of ctx.db
|
|
493
|
+
.query("payouts")
|
|
494
|
+
.withIndex("by_account_id", (q) => q.eq("accountId", args.accountId))) {
|
|
495
|
+
payoutCount += 1;
|
|
496
|
+
if (p.status === "paid") paidOut += p.amount;
|
|
497
|
+
if (payouts.length < previewLimit) payouts.push(p);
|
|
533
498
|
}
|
|
534
499
|
|
|
535
500
|
return { paidOut, payoutCount, payouts };
|
|
@@ -3,7 +3,7 @@ import { v } from "convex/values";
|
|
|
3
3
|
|
|
4
4
|
const component = defineComponent("betterStripe", {
|
|
5
5
|
env: {
|
|
6
|
-
//
|
|
6
|
+
// Server-side credentials used by core/queries.ts: getStripeMode.
|
|
7
7
|
// Declared as required so Convex validates its presence at push time; the
|
|
8
8
|
// installing app wires it in via
|
|
9
9
|
// `app.use(betterStripe, { env: { STRIPE_SECRET_KEY: app.env.STRIPE_SECRET_KEY } })`.
|
|
@@ -12,6 +12,7 @@ const component = defineComponent("betterStripe", {
|
|
|
12
12
|
// here: they are read by the app layer (registerRoutes) directly from the
|
|
13
13
|
// deployment env, never by the component.
|
|
14
14
|
STRIPE_SECRET_KEY: v.string(),
|
|
15
|
+
STRIPE_PUBLISHABLE_KEY: v.optional(v.string()),
|
|
15
16
|
},
|
|
16
17
|
});
|
|
17
18
|
export default component;
|