@fantastic.dev/better-stripe 0.4.0 → 0.5.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 +34 -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
|
@@ -80,6 +80,9 @@ function makeCtx(queryResult?: unknown) {
|
|
|
80
80
|
|
|
81
81
|
function makeStripe() {
|
|
82
82
|
return {
|
|
83
|
+
prices: {
|
|
84
|
+
retrieve: vi.fn().mockRejectedValue({ code: "resource_missing" }),
|
|
85
|
+
},
|
|
83
86
|
checkout: {
|
|
84
87
|
sessions: {
|
|
85
88
|
create: vi.fn(),
|
|
@@ -283,7 +286,9 @@ describe("createCheckoutSession", () => {
|
|
|
283
286
|
});
|
|
284
287
|
|
|
285
288
|
const createArg = stripe.checkout.sessions.create.mock.calls[0][0];
|
|
286
|
-
expect(createArg.line_items).toEqual([
|
|
289
|
+
expect(createArg.line_items).toEqual([
|
|
290
|
+
{ price: "price_seat", quantity: 5 },
|
|
291
|
+
]);
|
|
287
292
|
});
|
|
288
293
|
|
|
289
294
|
it("sets customer_account when accountId is provided", async () => {
|
|
@@ -679,8 +684,16 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
|
|
|
679
684
|
stripe.checkout.sessions.create.mockResolvedValue(sessionResponse());
|
|
680
685
|
const ctx = makeCtx();
|
|
681
686
|
const split = [
|
|
682
|
-
{
|
|
683
|
-
|
|
687
|
+
{
|
|
688
|
+
destinationAccountId: "acct_store",
|
|
689
|
+
role: "store" as const,
|
|
690
|
+
percent: 80,
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
destinationAccountId: "acct_aff",
|
|
694
|
+
role: "affiliate" as const,
|
|
695
|
+
percent: 5,
|
|
696
|
+
},
|
|
684
697
|
];
|
|
685
698
|
|
|
686
699
|
await createCheckoutSession(asStripe(stripe), makeComponent(), ctx, {
|
|
@@ -695,8 +708,12 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
|
|
|
695
708
|
|
|
696
709
|
const createArg = stripe.checkout.sessions.create.mock.calls[0][0];
|
|
697
710
|
expect(createArg.payment_intent_data.transfer_data).toBeUndefined();
|
|
698
|
-
expect(createArg.payment_intent_data.metadata.bsChargeType).toBe(
|
|
699
|
-
|
|
711
|
+
expect(createArg.payment_intent_data.metadata.bsChargeType).toBe(
|
|
712
|
+
"separate",
|
|
713
|
+
);
|
|
714
|
+
expect(
|
|
715
|
+
JSON.parse(createArg.payment_intent_data.metadata.bsSplit),
|
|
716
|
+
).toHaveLength(2);
|
|
700
717
|
|
|
701
718
|
const [, upsertArgs] = ctx.runMutation.mock.calls[0];
|
|
702
719
|
expect(upsertArgs.chargeType).toBe("separate");
|
|
@@ -725,7 +742,9 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
|
|
|
725
742
|
expect(createArg.subscription_data.transfer_data).toBeUndefined();
|
|
726
743
|
expect(createArg.subscription_data.application_fee_percent).toBeUndefined();
|
|
727
744
|
expect(createArg.subscription_data.metadata.bsChargeType).toBe("separate");
|
|
728
|
-
expect(
|
|
745
|
+
expect(
|
|
746
|
+
JSON.parse(createArg.subscription_data.metadata.bsSplit),
|
|
747
|
+
).toHaveLength(2);
|
|
729
748
|
|
|
730
749
|
const [, upsertArgs] = ctx.runMutation.mock.calls[0];
|
|
731
750
|
expect(upsertArgs.chargeType).toBe("separate");
|
|
@@ -743,7 +762,9 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
|
|
|
743
762
|
mode: "subscription",
|
|
744
763
|
returnUrl: "https://app.test/return",
|
|
745
764
|
accountId: "acct_buyer",
|
|
746
|
-
split: [
|
|
765
|
+
split: [
|
|
766
|
+
{ destinationAccountId: "acct_store", role: "store", percent: 90 },
|
|
767
|
+
],
|
|
747
768
|
feeConfig: { percent: 10 },
|
|
748
769
|
});
|
|
749
770
|
|
|
@@ -775,7 +796,9 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
|
|
|
775
796
|
|
|
776
797
|
const createArg = stripe.checkout.sessions.create.mock.calls[0][0];
|
|
777
798
|
expect(createArg.payment_intent_data.transfer_data).toBeUndefined();
|
|
778
|
-
expect(createArg.payment_intent_data.metadata.bsChargeType).toBe(
|
|
799
|
+
expect(createArg.payment_intent_data.metadata.bsChargeType).toBe(
|
|
800
|
+
"separate",
|
|
801
|
+
);
|
|
779
802
|
});
|
|
780
803
|
|
|
781
804
|
it("rejects split/destination/fee in setup mode", async () => {
|
|
@@ -789,7 +812,9 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
|
|
|
789
812
|
mode: "setup",
|
|
790
813
|
returnUrl: "https://app.test/return",
|
|
791
814
|
accountId: "acct_buyer",
|
|
792
|
-
split: [
|
|
815
|
+
split: [
|
|
816
|
+
{ destinationAccountId: "acct_store", role: "store", amount: 100 },
|
|
817
|
+
],
|
|
793
818
|
}),
|
|
794
819
|
).rejects.toThrow(/setup/i);
|
|
795
820
|
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
|
@@ -809,7 +834,8 @@ describe("createCheckoutSession — N-recipient split (BTS-21)", () => {
|
|
|
809
834
|
// A non-split caller trying to forge webhook instructions.
|
|
810
835
|
metadata: {
|
|
811
836
|
bsChargeType: "separate",
|
|
812
|
-
bsSplit:
|
|
837
|
+
bsSplit:
|
|
838
|
+
'[{"destinationAccountId":"acct_evil","role":"store","amount":9999}]',
|
|
813
839
|
plan: "pro",
|
|
814
840
|
},
|
|
815
841
|
});
|
|
@@ -881,6 +907,25 @@ describe("createCheckoutSession — price-on-platform validation (BTS-15)", () =
|
|
|
881
907
|
});
|
|
882
908
|
});
|
|
883
909
|
|
|
910
|
+
it("allows a platform price tagged to a store in the component catalog", async () => {
|
|
911
|
+
const stripe = makeStripe();
|
|
912
|
+
stripe.prices.retrieve.mockResolvedValue({ id: "price_1" });
|
|
913
|
+
stripe.checkout.sessions.create.mockResolvedValue(sessionResponse());
|
|
914
|
+
const ctx = routedCtx({
|
|
915
|
+
"products/queries/getPriceByStripeId": { stripeProductId: "prod_1" },
|
|
916
|
+
"products/queries/getProductByStripeId": { accountId: "acct_store" },
|
|
917
|
+
});
|
|
918
|
+
await createCheckoutSession(asStripe(stripe), makeComponent(), ctx, {
|
|
919
|
+
userId: "buyer_1",
|
|
920
|
+
stripePriceId: "price_1",
|
|
921
|
+
mode: "payment",
|
|
922
|
+
returnUrl: "https://app.test/return",
|
|
923
|
+
destinationAccountId: "acct_store",
|
|
924
|
+
});
|
|
925
|
+
expect(stripe.prices.retrieve).toHaveBeenCalledWith("price_1");
|
|
926
|
+
expect(stripe.checkout.sessions.create).toHaveBeenCalled();
|
|
927
|
+
});
|
|
928
|
+
|
|
884
929
|
it("allows a destination charge for a platform-owned price", async () => {
|
|
885
930
|
const stripe = makeStripe();
|
|
886
931
|
stripe.checkout.sessions.create.mockResolvedValue(sessionResponse());
|
|
@@ -1005,7 +1050,10 @@ describe("getCheckoutSessionByStripeId", () => {
|
|
|
1005
1050
|
|
|
1006
1051
|
describe("listCheckoutSessionsByUser", () => {
|
|
1007
1052
|
it("forwards userId and optional status filter and returns the rows", async () => {
|
|
1008
|
-
const ctx = makeCtx([
|
|
1053
|
+
const ctx = makeCtx([
|
|
1054
|
+
{ stripeSessionId: "cs_a" },
|
|
1055
|
+
{ stripeSessionId: "cs_b" },
|
|
1056
|
+
]);
|
|
1009
1057
|
const result = await listCheckoutSessionsByUser(makeComponent(), ctx, {
|
|
1010
1058
|
userId: "user_1",
|
|
1011
1059
|
status: "complete",
|
|
@@ -1157,9 +1205,9 @@ describe("createCheckoutSession — per-store statement descriptor (BTS-32)", ()
|
|
|
1157
1205
|
});
|
|
1158
1206
|
|
|
1159
1207
|
const createArg = stripe.checkout.sessions.create.mock.calls[0][0];
|
|
1160
|
-
expect(
|
|
1161
|
-
|
|
1162
|
-
)
|
|
1208
|
+
expect("statement_descriptor_suffix" in createArg.payment_intent_data).toBe(
|
|
1209
|
+
false,
|
|
1210
|
+
);
|
|
1163
1211
|
});
|
|
1164
1212
|
|
|
1165
1213
|
it("subscription mode: stashes bsStatementDescriptor for the invoice.created processor without clobbering fee markers", async () => {
|
|
@@ -58,12 +58,13 @@ function deriveFeeRow(params: CheckoutSessionCreateParams): {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Destination
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* Destination and separate charges use platform prices. Catalog accountId is a
|
|
62
|
+
* store attribution tag, not Stripe ownership (createProduct uses the platform).
|
|
63
|
+
* For tagged products, verify the price in the platform Stripe context before
|
|
64
|
+
* rejecting it. Untagged/unknown catalog entries are validated by Checkout.
|
|
65
65
|
*/
|
|
66
66
|
async function assertPlatformPrice(
|
|
67
|
+
stripe: Stripe,
|
|
67
68
|
component: Component,
|
|
68
69
|
ctx: RunCtx,
|
|
69
70
|
stripePriceId: string,
|
|
@@ -80,10 +81,23 @@ async function assertPlatformPrice(
|
|
|
80
81
|
)) as { accountId?: string } | null;
|
|
81
82
|
|
|
82
83
|
if (product?.accountId) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
try {
|
|
85
|
+
await stripe.prices.retrieve(stripePriceId);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// Only absence on the platform means the ownership check failed.
|
|
88
|
+
// Auth/network/rate-limit failures must not be misreported as ownership.
|
|
89
|
+
if (
|
|
90
|
+
!error ||
|
|
91
|
+
typeof error !== "object" ||
|
|
92
|
+
!("code" in error) ||
|
|
93
|
+
error.code !== "resource_missing"
|
|
94
|
+
)
|
|
95
|
+
throw error;
|
|
96
|
+
throwStripeError(
|
|
97
|
+
"INVALID_CONFIGURATION",
|
|
98
|
+
`Price ${stripePriceId} is unavailable on the platform account; destination charges require a platform-owned price.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
87
101
|
}
|
|
88
102
|
}
|
|
89
103
|
|
|
@@ -171,7 +185,9 @@ export async function createCheckoutSession(
|
|
|
171
185
|
? {
|
|
172
186
|
bsChargeType: "separate",
|
|
173
187
|
bsSplit: JSON.stringify(opts.split),
|
|
174
|
-
...(opts.feeConfig
|
|
188
|
+
...(opts.feeConfig
|
|
189
|
+
? { bsFeeConfig: JSON.stringify(opts.feeConfig) }
|
|
190
|
+
: {}),
|
|
175
191
|
}
|
|
176
192
|
: {};
|
|
177
193
|
|
|
@@ -314,7 +330,7 @@ export async function createCheckoutSession(
|
|
|
314
330
|
(isSeparate || feeRow.chargeType === "destination") &&
|
|
315
331
|
typeof finalPriceId === "string"
|
|
316
332
|
) {
|
|
317
|
-
await assertPlatformPrice(component, ctx, finalPriceId);
|
|
333
|
+
await assertPlatformPrice(stripe, component, ctx, finalPriceId);
|
|
318
334
|
}
|
|
319
335
|
|
|
320
336
|
const session = await stripe.checkout.sessions.create(finalSessionParams);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
|
|
3
|
+
import { validateSplit } from "../core/fees.js";
|
|
4
|
+
import type { SplitRecipient } from "../../component/lib/fees.js";
|
|
5
|
+
|
|
6
|
+
/** Recover routing from Stripe for both webhook delivery and explicit syncs. */
|
|
7
|
+
export function subscriptionRouting(subscription: Stripe.Subscription) {
|
|
8
|
+
const destination = subscription.transfer_data?.destination;
|
|
9
|
+
if (destination) {
|
|
10
|
+
return {
|
|
11
|
+
chargeType: "destination" as const,
|
|
12
|
+
destinationAccountId:
|
|
13
|
+
typeof destination === "string" ? destination : destination.id,
|
|
14
|
+
applicationFeePercent: subscription.application_fee_percent ?? undefined,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (subscription.metadata?.bsChargeType === "separate") {
|
|
18
|
+
const split = JSON.parse(
|
|
19
|
+
subscription.metadata.bsSplit ?? "null",
|
|
20
|
+
) as SplitRecipient[];
|
|
21
|
+
validateSplit(split);
|
|
22
|
+
return { chargeType: "separate" as const, splitRecipients: split };
|
|
23
|
+
}
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
@@ -110,7 +110,9 @@ describe("getSubscription", () => {
|
|
|
110
110
|
expect(refPath(ctx.runQuery.mock.calls[0])).toBe(
|
|
111
111
|
"betterStripe/billing/queries/getSubscription",
|
|
112
112
|
);
|
|
113
|
-
expect(ctx.runQuery.mock.calls[0][1]).toEqual({
|
|
113
|
+
expect(ctx.runQuery.mock.calls[0][1]).toEqual({
|
|
114
|
+
subscriptionId: "internal_1",
|
|
115
|
+
});
|
|
114
116
|
});
|
|
115
117
|
|
|
116
118
|
it("returns null when the row is missing", async () => {
|
|
@@ -492,6 +494,10 @@ describe("syncAllSubscriptions", () => {
|
|
|
492
494
|
{
|
|
493
495
|
id: "sub_1",
|
|
494
496
|
customer: { id: "cus_1" },
|
|
497
|
+
customer_account: "acct_buyer",
|
|
498
|
+
transfer_data: { destination: { id: "acct_store" } },
|
|
499
|
+
application_fee_percent: 10,
|
|
500
|
+
pause_collection: { behavior: "void" },
|
|
495
501
|
status: "trialing",
|
|
496
502
|
cancel_at_period_end: false,
|
|
497
503
|
canceled_at: null,
|
|
@@ -527,7 +533,10 @@ describe("syncAllSubscriptions", () => {
|
|
|
527
533
|
);
|
|
528
534
|
expect(ctx.runMutation.mock.calls[0][1]).toEqual({
|
|
529
535
|
stripeSubscriptionId: "sub_1",
|
|
530
|
-
accountId: "
|
|
536
|
+
accountId: "acct_buyer",
|
|
537
|
+
chargeType: "destination",
|
|
538
|
+
destinationAccountId: "acct_store",
|
|
539
|
+
applicationFeePercent: 10,
|
|
531
540
|
userId: "user_1",
|
|
532
541
|
orgId: "org_1",
|
|
533
542
|
status: "trialing",
|
|
@@ -536,6 +545,7 @@ describe("syncAllSubscriptions", () => {
|
|
|
536
545
|
currentPeriodStart: "2021-01-01T00:00:00.000Z",
|
|
537
546
|
currentPeriodEnd: "2021-02-01T00:00:00.000Z",
|
|
538
547
|
cancelAtPeriodEnd: false,
|
|
548
|
+
isCollectionPaused: true,
|
|
539
549
|
canceledAt: undefined,
|
|
540
550
|
isTrialing: true,
|
|
541
551
|
trialStart: "2021-01-01T00:00:00.000Z",
|
|
@@ -557,7 +567,9 @@ describe("syncAllSubscriptions", () => {
|
|
|
557
567
|
trial_start: null,
|
|
558
568
|
trial_end: null,
|
|
559
569
|
metadata: { user_id: "user_2", org_id: "org_2" },
|
|
560
|
-
items: {
|
|
570
|
+
items: {
|
|
571
|
+
data: [{ id: "si_2", price: { id: "price_2" }, quantity: 1 }],
|
|
572
|
+
},
|
|
561
573
|
},
|
|
562
574
|
]),
|
|
563
575
|
);
|
|
@@ -719,13 +731,18 @@ describe("createSubscription (BTS-17)", () => {
|
|
|
719
731
|
stripe.subscriptions.create.mockResolvedValue(createdSub());
|
|
720
732
|
const ctx = makeCtx();
|
|
721
733
|
|
|
722
|
-
const result = await createSubscription(
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
734
|
+
const result = await createSubscription(
|
|
735
|
+
asStripe(stripe),
|
|
736
|
+
makeComponent(),
|
|
737
|
+
ctx,
|
|
738
|
+
{
|
|
739
|
+
userId: "buyer_1",
|
|
740
|
+
customerAccount: "acct_buyer",
|
|
741
|
+
stripePriceId: "price_1",
|
|
742
|
+
destinationAccountId: "acct_store",
|
|
743
|
+
feeConfig: { percent: 10 },
|
|
744
|
+
},
|
|
745
|
+
);
|
|
729
746
|
|
|
730
747
|
const createArg = stripe.subscriptions.create.mock.calls[0][0];
|
|
731
748
|
expect(createArg.customer_account).toBe("acct_buyer");
|
|
@@ -744,7 +761,26 @@ describe("createSubscription (BTS-17)", () => {
|
|
|
744
761
|
destinationAccountId: "acct_store",
|
|
745
762
|
applicationFeePercent: 10,
|
|
746
763
|
});
|
|
747
|
-
expect(result).toEqual({
|
|
764
|
+
expect(result).toEqual({
|
|
765
|
+
stripeSubscriptionId: "sub_new",
|
|
766
|
+
status: "active",
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("rejects an unknown Stripe status before writing the ledger", async () => {
|
|
771
|
+
const stripe = makeStripe();
|
|
772
|
+
stripe.subscriptions.create.mockResolvedValue(
|
|
773
|
+
createdSub({ status: "future_status" }),
|
|
774
|
+
);
|
|
775
|
+
const ctx = makeCtx();
|
|
776
|
+
await expect(
|
|
777
|
+
createSubscription(asStripe(stripe), makeComponent(), ctx, {
|
|
778
|
+
userId: "buyer_1",
|
|
779
|
+
customerAccount: "acct_buyer",
|
|
780
|
+
stripePriceId: "price_1",
|
|
781
|
+
}),
|
|
782
|
+
).rejects.toThrow("Unsupported subscription status: future_status");
|
|
783
|
+
expect(ctx.runMutation).not.toHaveBeenCalled();
|
|
748
784
|
});
|
|
749
785
|
|
|
750
786
|
it("flags percent+fixed fees for per-invoice computation", async () => {
|
|
@@ -841,7 +877,9 @@ describe("createSubscription (BTS-17)", () => {
|
|
|
841
877
|
userId: "buyer_1",
|
|
842
878
|
customerAccount: "acct_buyer",
|
|
843
879
|
stripePriceId: "price_1",
|
|
844
|
-
split: [
|
|
880
|
+
split: [
|
|
881
|
+
{ destinationAccountId: "acct_store", role: "store", percent: 90 },
|
|
882
|
+
],
|
|
845
883
|
feeConfig: { percent: 10 },
|
|
846
884
|
});
|
|
847
885
|
|
|
@@ -882,13 +920,15 @@ describe("createSubscription (BTS-17)", () => {
|
|
|
882
920
|
|
|
883
921
|
describe("groupSubscriptionsByStore (BTS-19)", () => {
|
|
884
922
|
it("groups a buyer's subscriptions by store, preserving first-seen order", () => {
|
|
885
|
-
const subs: {
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
923
|
+
const subs: {
|
|
924
|
+
stripeSubscriptionId: string;
|
|
925
|
+
destinationAccountId?: string;
|
|
926
|
+
}[] = [
|
|
927
|
+
{ stripeSubscriptionId: "s1", destinationAccountId: "store_a" },
|
|
928
|
+
{ stripeSubscriptionId: "s2", destinationAccountId: "store_b" },
|
|
929
|
+
{ stripeSubscriptionId: "s3", destinationAccountId: "store_a" },
|
|
930
|
+
{ stripeSubscriptionId: "s4" }, // no store → platform-direct
|
|
931
|
+
];
|
|
892
932
|
|
|
893
933
|
const grouped = groupSubscriptionsByStore(subs);
|
|
894
934
|
|
|
@@ -897,15 +937,15 @@ describe("groupSubscriptionsByStore (BTS-19)", () => {
|
|
|
897
937
|
"store_b",
|
|
898
938
|
null,
|
|
899
939
|
]);
|
|
900
|
-
expect(
|
|
901
|
-
|
|
902
|
-
)
|
|
903
|
-
expect(grouped[1].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual(
|
|
904
|
-
"s2",
|
|
905
|
-
|
|
906
|
-
expect(grouped[2].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual(
|
|
907
|
-
"s4",
|
|
908
|
-
|
|
940
|
+
expect(grouped[0].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual(
|
|
941
|
+
["s1", "s3"],
|
|
942
|
+
);
|
|
943
|
+
expect(grouped[1].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual(
|
|
944
|
+
["s2"],
|
|
945
|
+
);
|
|
946
|
+
expect(grouped[2].subscriptions.map((s) => s.stripeSubscriptionId)).toEqual(
|
|
947
|
+
["s4"],
|
|
948
|
+
);
|
|
909
949
|
});
|
|
910
950
|
|
|
911
951
|
it("returns an empty array for no subscriptions", () => {
|
|
@@ -985,3 +1025,25 @@ describe("createSubscription — per-store statement descriptor (BTS-32)", () =>
|
|
|
985
1025
|
expect(ctx.runQuery).not.toHaveBeenCalled();
|
|
986
1026
|
});
|
|
987
1027
|
});
|
|
1028
|
+
|
|
1029
|
+
it("passes an explicit attached payment method to Stripe for the first invoice", async () => {
|
|
1030
|
+
const stripe = makeStripe();
|
|
1031
|
+
stripe.subscriptions.create.mockResolvedValue({
|
|
1032
|
+
id: "sub_card",
|
|
1033
|
+
status: "active",
|
|
1034
|
+
items: { data: [] },
|
|
1035
|
+
});
|
|
1036
|
+
await createSubscription(asStripe(stripe), makeComponent(), makeCtx(), {
|
|
1037
|
+
userId: "user",
|
|
1038
|
+
customerAccount: "acct_buyer",
|
|
1039
|
+
stripePriceId: "price_monthly",
|
|
1040
|
+
defaultPaymentMethod: "pm_attached",
|
|
1041
|
+
});
|
|
1042
|
+
expect(stripe.subscriptions.create).toHaveBeenCalledWith(
|
|
1043
|
+
expect.objectContaining({
|
|
1044
|
+
customer_account: "acct_buyer",
|
|
1045
|
+
default_payment_method: "pm_attached",
|
|
1046
|
+
}),
|
|
1047
|
+
undefined,
|
|
1048
|
+
);
|
|
1049
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { subscriptionRouting } from "./subscriptionRouting.js";
|
|
2
|
+
import { resolveOwnerAccount } from "../utils/owner.js";
|
|
1
3
|
import type Stripe from "stripe";
|
|
2
4
|
|
|
3
5
|
import type { SubscriptionStatus } from "../../component/billing/validators.js";
|
|
@@ -10,7 +12,10 @@ import {
|
|
|
10
12
|
stripReservedMetadata,
|
|
11
13
|
} from "../helpers.js";
|
|
12
14
|
import { throwStripeError } from "../errors.js";
|
|
13
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
PlatformFeeConfig,
|
|
17
|
+
StripeComponentSubscription,
|
|
18
|
+
} from "../types.js";
|
|
14
19
|
import { componentRef } from "../webhooks/helpers.js";
|
|
15
20
|
|
|
16
21
|
// =============================================================================
|
|
@@ -23,7 +28,10 @@ import { componentRef } from "../webhooks/helpers.js";
|
|
|
23
28
|
* the Convex action/mutation boundary instead of propagating as a raw `Error`.
|
|
24
29
|
* This is the Plan 006 convention; see `src/client/errors.ts`.
|
|
25
30
|
*/
|
|
26
|
-
async function callStripe<T>(
|
|
31
|
+
async function callStripe<T>(
|
|
32
|
+
message: string,
|
|
33
|
+
fn: () => Promise<T>,
|
|
34
|
+
): Promise<T> {
|
|
27
35
|
try {
|
|
28
36
|
return await fn();
|
|
29
37
|
} catch (err) {
|
|
@@ -53,6 +61,8 @@ export async function createSubscription(
|
|
|
53
61
|
split?: SplitRecipient[];
|
|
54
62
|
feeConfig?: PlatformFeeConfig;
|
|
55
63
|
trialDays?: number;
|
|
64
|
+
/** Attached payment method to use for this subscription. */
|
|
65
|
+
defaultPaymentMethod?: string;
|
|
56
66
|
/** Platform-default statement-descriptor suffix (BTS-32); see checkout.ts. */
|
|
57
67
|
defaultStatementDescriptorSuffix?: string;
|
|
58
68
|
metadata?: Record<string, string>;
|
|
@@ -105,6 +115,9 @@ export async function createSubscription(
|
|
|
105
115
|
|
|
106
116
|
const params: Stripe.SubscriptionCreateParams = {
|
|
107
117
|
customer_account: opts.customerAccount,
|
|
118
|
+
...(opts.defaultPaymentMethod
|
|
119
|
+
? { default_payment_method: opts.defaultPaymentMethod }
|
|
120
|
+
: {}),
|
|
108
121
|
items: [{ price: opts.stripePriceId }],
|
|
109
122
|
metadata,
|
|
110
123
|
};
|
|
@@ -125,7 +138,9 @@ export async function createSubscription(
|
|
|
125
138
|
...metadata,
|
|
126
139
|
bsChargeType: "separate",
|
|
127
140
|
bsSplit: JSON.stringify(opts.split),
|
|
128
|
-
...(opts.feeConfig
|
|
141
|
+
...(opts.feeConfig
|
|
142
|
+
? { bsFeeConfig: JSON.stringify(opts.feeConfig) }
|
|
143
|
+
: {}),
|
|
129
144
|
};
|
|
130
145
|
feeRow.chargeType = "separate";
|
|
131
146
|
feeRow.splitRecipients = opts.split;
|
|
@@ -162,6 +177,25 @@ export async function createSubscription(
|
|
|
162
177
|
stripe.subscriptions.create(params, requestOpts),
|
|
163
178
|
);
|
|
164
179
|
|
|
180
|
+
// Stripe 23 permits future status strings; our ledger accepts only these
|
|
181
|
+
// known statuses. Reject an unknown value before attempting to persist it.
|
|
182
|
+
const isKnownStatus = (value: string): value is SubscriptionStatus =>
|
|
183
|
+
value === "active" ||
|
|
184
|
+
value === "canceled" ||
|
|
185
|
+
value === "incomplete" ||
|
|
186
|
+
value === "incomplete_expired" ||
|
|
187
|
+
value === "past_due" ||
|
|
188
|
+
value === "paused" ||
|
|
189
|
+
value === "trialing" ||
|
|
190
|
+
value === "unpaid";
|
|
191
|
+
const status = sub.status;
|
|
192
|
+
if (!isKnownStatus(status)) {
|
|
193
|
+
throwStripeError(
|
|
194
|
+
"STRIPE_API_ERROR",
|
|
195
|
+
`Unsupported subscription status: ${status}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
165
199
|
const firstItem = sub.items?.data?.[0];
|
|
166
200
|
await runMutationOrThrow(
|
|
167
201
|
ctx,
|
|
@@ -171,10 +205,12 @@ export async function createSubscription(
|
|
|
171
205
|
accountId: opts.customerAccount,
|
|
172
206
|
userId: opts.userId,
|
|
173
207
|
orgId: opts.orgId,
|
|
174
|
-
status
|
|
208
|
+
status,
|
|
175
209
|
priceId: firstItem?.price?.id ?? opts.stripePriceId,
|
|
176
210
|
quantity: firstItem?.quantity ?? undefined,
|
|
177
|
-
currentPeriodStart: epochToIso(
|
|
211
|
+
currentPeriodStart: epochToIso(
|
|
212
|
+
firstItem?.current_period_start ?? undefined,
|
|
213
|
+
),
|
|
178
214
|
currentPeriodEnd: epochToIso(firstItem?.current_period_end ?? undefined),
|
|
179
215
|
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
|
180
216
|
canceledAt: epochToIso(sub.canceled_at),
|
|
@@ -186,7 +222,7 @@ export async function createSubscription(
|
|
|
186
222
|
},
|
|
187
223
|
);
|
|
188
224
|
|
|
189
|
-
return { stripeSubscriptionId: sub.id, status
|
|
225
|
+
return { stripeSubscriptionId: sub.id, status };
|
|
190
226
|
}
|
|
191
227
|
|
|
192
228
|
export async function getSubscription(
|
|
@@ -255,7 +291,8 @@ export async function updateSubscriptionQuantity(
|
|
|
255
291
|
stripe.subscriptions.retrieve(opts.stripeSubscriptionId),
|
|
256
292
|
);
|
|
257
293
|
const itemId = sub.items?.data?.[0]?.id;
|
|
258
|
-
if (!itemId)
|
|
294
|
+
if (!itemId)
|
|
295
|
+
throwStripeError("SUBSCRIPTION_UPDATE_FAILED", "Subscription has no items");
|
|
259
296
|
await callStripe("Failed to update subscription quantity", () =>
|
|
260
297
|
stripe.subscriptionItems.update(itemId, { quantity: opts.quantity }),
|
|
261
298
|
);
|
|
@@ -263,8 +300,8 @@ export async function updateSubscriptionQuantity(
|
|
|
263
300
|
}
|
|
264
301
|
|
|
265
302
|
/**
|
|
266
|
-
* Pause collection on a subscription. Sets `pause_collection`
|
|
267
|
-
* the subscription
|
|
303
|
+
* Pause collection on a subscription. Sets `pause_collection` without changing
|
|
304
|
+
* the subscription status; the component DB is updated when the
|
|
268
305
|
* resulting `customer.subscription.updated` webhook arrives.
|
|
269
306
|
*/
|
|
270
307
|
export async function pauseSubscription(
|
|
@@ -288,10 +325,8 @@ export async function pauseSubscription(
|
|
|
288
325
|
}
|
|
289
326
|
|
|
290
327
|
/**
|
|
291
|
-
* Resume
|
|
292
|
-
*
|
|
293
|
-
* (`Emptyable<PauseCollection>`) clears the field and Stripe returns the
|
|
294
|
-
* subscription to its prior active status.
|
|
328
|
+
* Resume payment collection by clearing `pause_collection`. The empty-string
|
|
329
|
+
* sentinel clears the field without changing the subscription status.
|
|
295
330
|
*/
|
|
296
331
|
export async function resumeSubscription(
|
|
297
332
|
stripe: Stripe,
|
|
@@ -326,7 +361,8 @@ export async function updateSubscriptionPrice(
|
|
|
326
361
|
stripe.subscriptions.retrieve(opts.stripeSubscriptionId),
|
|
327
362
|
);
|
|
328
363
|
const itemId = sub.items?.data?.[0]?.id;
|
|
329
|
-
if (!itemId)
|
|
364
|
+
if (!itemId)
|
|
365
|
+
throwStripeError("SUBSCRIPTION_UPDATE_FAILED", "Subscription has no items");
|
|
330
366
|
await callStripe("Failed to update subscription price", () =>
|
|
331
367
|
stripe.subscriptions.update(opts.stripeSubscriptionId, {
|
|
332
368
|
items: [{ id: itemId, price: opts.stripePriceId }],
|
|
@@ -546,10 +582,8 @@ export async function syncAllSubscriptions(
|
|
|
546
582
|
componentRef(component, "billing/mutations/upsertSubscription"),
|
|
547
583
|
{
|
|
548
584
|
stripeSubscriptionId: sub.id,
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
? sub.customer
|
|
552
|
-
: (sub.customer?.id ?? ""),
|
|
585
|
+
...subscriptionRouting(sub),
|
|
586
|
+
accountId: resolveOwnerAccount(sub) ?? "",
|
|
553
587
|
userId,
|
|
554
588
|
orgId,
|
|
555
589
|
status: sub.status,
|
|
@@ -558,6 +592,7 @@ export async function syncAllSubscriptions(
|
|
|
558
592
|
currentPeriodStart: epochToIso(periodStart),
|
|
559
593
|
currentPeriodEnd: epochToIso(periodEnd),
|
|
560
594
|
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
|
595
|
+
isCollectionPaused: !!sub.pause_collection,
|
|
561
596
|
canceledAt: epochToIso(sub.canceled_at),
|
|
562
597
|
isTrialing: sub.status === "trialing",
|
|
563
598
|
trialStart: epochToIso(sub.trial_start),
|
package/src/client/constants.ts
CHANGED
|
@@ -12,4 +12,4 @@ import type { StripeApiVersion } from "./stripe-types.js";
|
|
|
12
12
|
* component (it pins every Stripe client it creates), so it warrants a
|
|
13
13
|
* component release and changelog entry.
|
|
14
14
|
*/
|
|
15
|
-
export const STRIPE_API_VERSION: StripeApiVersion = "2026-
|
|
15
|
+
export const STRIPE_API_VERSION: StripeApiVersion = "2026-09-30.endive";
|