mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
fix(stripe): charge the membership price in the customer pinned currency instead of 500ing (#4945)
* fix(stripe): charge the sibling membership price in the customer's pinned currency
Stripe pins a customer to ONE billing currency the first time they are invoiced
(`customer.currency`) and it is immutable. Every later subscription price for
that customer must be payable in it, or Stripe rejects the call:
The price specified only supports `usd`. This doesn't match the expected
currency: `aud`.
That is a raw throw out of `checkout.sessions.create` / `subscriptions.update`,
so it reached the client as a tRPC INTERNAL_SERVER_ERROR (HTTP 500).
The first version of this branch read the situation as "our membership Prices
are single-currency USD, so a pinned customer cannot subscribe at all" and
classified the error into a 4xx. That premise is wrong. Checked against the
Product/Price rows: every active Stripe membership tier carries seven active
monthly sibling Prices — aud, cad, eur, gbp, jpy, krw, usd — exactly one per
currency, and `getPlans` already ships the whole set to the pricing page. The
membership IS purchasable on a pinned account; nothing was resolving which
sibling to charge.
So the remedy is a substitution, not a classification.
Server-side, in `createSubscribeSession`, because that is the only place the
answer is knowable. `customer.currency` is a Stripe-side fact with no column in
our database and no endpoint that exposes it, so the price picker cannot choose
correctly however it is written. Resolving here also covers the plan-change
path and any caller that never goes through the pricing page. The resolution is
scoped to the same product, active, recurring, the same interval and the same
interval_count — each of those narrows a way the substitute could be the wrong
thing to charge, and the product scope comes off the Stripe Price object, which
keeps the lookup inside Stripe's catalog rather than reaching a row belonging to
the other payment provider.
A typed BAD_REQUEST survives only as the genuine fallback, in the two cases
where no single correct answer exists: the membership is not sold in that
currency at all, or more than one active price matches and charging either would
be charging an amount nobody chose. Neither message claims the membership cannot
be purchased — the old one did, and that sentence was false.
Client-side, the plan card now preselects the sibling in the currency of the
member's existing subscription. The server substitutes either way, so this is
not what makes the purchase work; it is so the figure on the card is the figure
that gets charged. That matters most on the plan-change path, which takes the
money immediately with no Stripe-hosted confirmation screen in between. The
existing subscription's price is the only pinned-currency evidence available to
the browser, and it is sound evidence: Stripe accepted that price, so by its own
rule its currency is the pinned one.
Currency case is normalised on every comparison. Not cosmetic here: the
Product/Price tables hold the same currencies in both spellings because the two
payment providers differ (Stripe lower-case, the other upper-case), the plan
card is provider-generic, and Stripe returns `customer.currency` lower-case. A
case-sensitive comparison matches nothing for one of the two catalogs. The
currency dropdown's labels are upper-cased in the data rather than only by the
`uppercase` CSS class, so the label does not depend on which catalog a product
came from.
Regression matrix — the server test was watched fail on pre-change code:
at origin/main (c6da1a2dc4): Test Files 1 failed (1) | Tests 11 failed | 3 passed (14)
at HEAD: Test Files 1 passed (1) | Tests 14 passed (14)
The 3 that pass while red are the deliberate controls — the unpinned customer,
the case-insensitive match, and the multi-currency Price — all of which proceed
unchanged before and after.
`pickInitialPriceId` and its 11 tests are new code, so they are an INVARIANT
GUARD, not regression coverage, and are labelled that way in the file.
Mutation sweep, 11 mutants, every one killed, and 9 of the 11 killed by exactly
one test — the one that claims to cover it. Each mutant was applied to a
verified-pristine file and the restore was asserted by digest afterwards, after
a first run silently carried one mutant forward and inflated every later result.
M1 customer-side toLowerCase dropped -> the upper-case pin test (+1)
M2 price-side toLowerCase dropped -> the both-sides case test
M3 interval_count filter removed -> the quarterly-substitute test
M4 product scope dropped from the lookup -> the lookup-scope test
M5 ambiguity resolved by picking the first -> the refuses-to-guess test
M6 currency_options expand dropped -> the expand test
M7 Checkout charges the requested id -> the three substitution tests
M8 currency_options check removed -> the multi-currency-Price test
M9 pick: pinned-side toLowerCase dropped -> upper-case pin vs lower rows
M10 pick: default-branch toLowerCase dropped -> default differs only in case
M11 pick: pinned-currency search removed -> the three pinned-currency tests
Checks: pnpm typecheck 0 errors; 717 files / 12,444 tests passed across
services, Subscriptions and Stripe components; eslint 0 errors on the changed
files; prettier clean.
Not verified here: this has not been exercised against live Stripe, and
`customer.currency` was not read off the failing customer — that needs
credentials and operator authorisation. If the production rejection came from
some other Stripe-side mechanism, the resolver returns early and changes
nothing. One `customers.retrieve` against the id in the original error settles
it.
Out of scope and untouched: `membership-gift.service.ts` creates the recipient's
subscription from the same catalog AFTER the gifter has paid, so the same class
of failure is worse there. Tracked separately. Also untouched: the duplicate and
case-inconsistent Price rows themselves, which are data work.
* fix(pricing): derive the plan card's price selection so a late-arriving currency pin lands
The client-side half of this PR was inert on the page it was written for.
`pickInitialPriceId` was called inside a `useState` initializer, so it ran once at
mount — and on /pricing the card mounts before the subscription is known:
- src/pages/pricing/index.tsx prefetches subscriptions.getPlans in
getServerSideProps, so the plans are hydrated on first paint.
- getUserSubscription is a plain client query (memberships.util.ts), so it is
undefined on that first client render.
- MembershipPlans gates the grid on productsLoading alone, so the card renders
in that window.
- The only remount vector is key={interval-product.id}, and interval is set from
subscription?.price?.interval ?? 'month' — a no-op for a monthly member, which
is exactly the population this PR is about.
So an AUD-pinned member hard-loading /pricing got the USD row preselected, and it
stayed that way for the page's whole life while the rest of the card re-rendered
around it and started offering Upgrade. The server then substitutes the AUD sibling
and subscriptions.update charges immediately with no confirmation screen, so the
amount billed was one the card never displayed.
The selection is now DERIVED on every render by useSelectedPriceId; state holds only
an explicit choice made in the currency picker. Chosen over gating MembershipPlans
on subscriptionLoading (that page prefetches its plans specifically so they paint
without a spinner — gating would undo that for every logged-in visitor) and over
adding the currency to the PlanCard key (a remount discards the user's own picker
choice and re-mounts the card's media).
Regression coverage is behavioural, on the wiring rather than the pure rule:
src/components/Subscriptions/__tests__/useSelectedPriceId.test.ts mounts with the
pin unknown, resolves it, and asserts the shown row follows. With the previous
wiring transplanted verbatim into the hook it fails on that assertion; it also
covers the warm-cache path (pinned row on the FIRST render, no second pass) and
that an explicit picker choice is never recomputed away.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { ButtonProps } from '@mantine/core';
|
||||
import { Button, Card, Center, Divider, Group, Select, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconChevronDown, IconGift } from '@tabler/icons-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { dialogStore } from '~/components/Dialog/dialogStore';
|
||||
import { EdgeMedia } from '~/components/EdgeMedia/EdgeMedia';
|
||||
import { NextLink as Link } from '~/components/NextLink/NextLink';
|
||||
@@ -18,6 +18,7 @@ import type { SubscriptionProductMetadata } from '~/server/schema/subscriptions.
|
||||
import type { SubscriptionPlan, UserSubscription } from '~/server/services/subscriptions.service';
|
||||
import { capitalize, getStripeCurrencyDisplay } from '~/utils/string-helpers';
|
||||
import { getPlanDetails } from '~/components/Subscriptions/getPlanDetails';
|
||||
import { useSelectedPriceId } from '~/components/Subscriptions/useSelectedPriceId';
|
||||
import { PaymentProvider } from '~/shared/utils/prisma/enums';
|
||||
import { getBuzzMembershipPrice, isMembershipActive } from '~/shared/utils/buzz-membership';
|
||||
import { numberWithCommas } from '~/utils/number-helpers';
|
||||
@@ -78,11 +79,21 @@ export function PlanCard({ product, subscription }: PlanCardProps) {
|
||||
const defaultPriceId = _isActivePlan
|
||||
? subscription?.price.id ?? product.defaultPriceId
|
||||
: product.defaultPriceId;
|
||||
const [priceId, setPriceId] = useState<string | null>(
|
||||
product.prices.find((p) => p.id === defaultPriceId)?.id ??
|
||||
product.prices.find((p) => p.currency.toLowerCase() === 'usd')?.id ??
|
||||
product.prices[0].id
|
||||
);
|
||||
const [priceId, setPriceId] = useSelectedPriceId({
|
||||
prices: product.prices,
|
||||
defaultPriceId,
|
||||
// Stripe pins a customer to one billing currency on their first invoice, and it cannot
|
||||
// change. We cannot read that pin client-side, but an existing subscription's price is
|
||||
// proof of it: Stripe accepted that price, so its currency is the pinned one. Preselect
|
||||
// the sibling in that currency so the amount shown is the amount charged — the server
|
||||
// substitutes it either way, and the plan-change path charges with no confirmation
|
||||
// screen in between.
|
||||
//
|
||||
// `subscription` arrives LATE on /pricing — the plans are prefetched server-side, the
|
||||
// subscription is a plain client query — so this hook derives the selection on every
|
||||
// render rather than freezing it at mount. See useSelectedPriceId for why.
|
||||
pinnedCurrency: subscription?.price.currency,
|
||||
});
|
||||
const price = product.prices.find((p) => p.id === priceId) ?? product.prices[0];
|
||||
const siteBuzzType = features.isGreen ? 'green' : 'yellow';
|
||||
const buzzPrice = getBuzzMembershipPrice({
|
||||
@@ -243,7 +254,14 @@ export function PlanCard({ product, subscription }: PlanCardProps) {
|
||||
</Group>
|
||||
{!isBuzzPurchase && (
|
||||
<Select
|
||||
data={product.prices.map((p) => ({ label: p.currency, value: p.id }))}
|
||||
// Upper-cased in the data, not only by the `uppercase` class below:
|
||||
// the two payment providers spell the same currency differently
|
||||
// (Stripe lower-case, the other upper-case), so the label a user sees
|
||||
// should not depend on which catalog the product came from.
|
||||
data={product.prices.map((p) => ({
|
||||
label: p.currency.toUpperCase(),
|
||||
value: p.id,
|
||||
}))}
|
||||
value={priceId}
|
||||
onChange={(val) => val && setPriceId(val)}
|
||||
allowDeselect={false}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pickInitialPriceId } from '~/components/Subscriptions/pickInitialPriceId';
|
||||
|
||||
/**
|
||||
* INVARIANT GUARD, not a regression test — `pickInitialPriceId` is new in this change, so
|
||||
* none of these could have been watched fail on pre-change code. The regression test for
|
||||
* this change is the server-side one in
|
||||
* `src/server/services/__tests__/stripe.createSubscribeSession.currency.test.ts`, which is
|
||||
* red at the base commit. These pin the selection rule so it cannot drift silently.
|
||||
*
|
||||
* What the rule is for: Stripe pins a customer to one billing currency on their first
|
||||
* invoice and it is immutable. The server substitutes the correct sibling price before it
|
||||
* calls Stripe either way, so a purchase succeeds regardless of what this returns — this
|
||||
* exists so the figure on the plan card is the figure that gets charged, which matters most
|
||||
* on the plan-change path where there is no Stripe-hosted confirmation screen.
|
||||
*/
|
||||
|
||||
// Lower-case codes, as Stripe's API returns them and as our Stripe rows carry them.
|
||||
const STRIPE_ROWS = [
|
||||
{ id: 'gold_usd', currency: 'usd' },
|
||||
{ id: 'gold_aud', currency: 'aud' },
|
||||
{ id: 'gold_eur', currency: 'eur' },
|
||||
];
|
||||
|
||||
// Upper-case codes, as the other payment provider's rows carry them. This component is
|
||||
// provider-generic — `getPlans` takes the provider as an argument — so both spellings reach
|
||||
// it, and a case-sensitive comparison silently matches nothing for one of the two catalogs.
|
||||
const OTHER_PROVIDER_ROWS = [
|
||||
{ id: 'gold_USD', currency: 'USD' },
|
||||
{ id: 'gold_AUD', currency: 'AUD' },
|
||||
];
|
||||
|
||||
describe('pickInitialPriceId — no pinned currency (behaviour before this change)', () => {
|
||||
it('returns the product default', () => {
|
||||
expect(pickInitialPriceId({ prices: STRIPE_ROWS, defaultPriceId: 'gold_usd' })).toBe(
|
||||
'gold_usd'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to USD when the default is not among the prices', () => {
|
||||
expect(pickInitialPriceId({ prices: STRIPE_ROWS, defaultPriceId: 'not_in_list' })).toBe(
|
||||
'gold_usd'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the first price when there is no USD row either', () => {
|
||||
expect(
|
||||
pickInitialPriceId({
|
||||
prices: [{ id: 'gold_jpy', currency: 'jpy' }, ...STRIPE_ROWS.slice(1)],
|
||||
defaultPriceId: 'not_in_list',
|
||||
})
|
||||
).toBe('gold_jpy');
|
||||
});
|
||||
|
||||
it('returns null rather than throwing on an empty price list', () => {
|
||||
expect(pickInitialPriceId({ prices: [], defaultPriceId: 'gold_usd' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickInitialPriceId — pinned currency', () => {
|
||||
it('preselects the sibling in the pinned currency instead of the USD default', () => {
|
||||
expect(
|
||||
pickInitialPriceId({
|
||||
prices: STRIPE_ROWS,
|
||||
defaultPriceId: 'gold_usd',
|
||||
pinnedCurrency: 'aud',
|
||||
})
|
||||
).toBe('gold_aud');
|
||||
});
|
||||
|
||||
it('keeps the default when the default is already in the pinned currency', () => {
|
||||
// Exact-id match wins over a currency search, so a card showing the plan the member
|
||||
// already holds keeps that member's own price rather than some other row in the same
|
||||
// currency. Two USD rows here so the two rules can disagree.
|
||||
expect(
|
||||
pickInitialPriceId({
|
||||
prices: [{ id: 'gold_usd_legacy', currency: 'usd' }, ...STRIPE_ROWS],
|
||||
defaultPriceId: 'gold_usd',
|
||||
pinnedCurrency: 'usd',
|
||||
})
|
||||
).toBe('gold_usd');
|
||||
});
|
||||
|
||||
it('falls back to the default when the plan is not sold in the pinned currency', () => {
|
||||
// The server raises a typed error in this case; the card still has to render something.
|
||||
expect(
|
||||
pickInitialPriceId({
|
||||
prices: STRIPE_ROWS,
|
||||
defaultPriceId: 'gold_usd',
|
||||
pinnedCurrency: 'krw',
|
||||
})
|
||||
).toBe('gold_usd');
|
||||
});
|
||||
|
||||
it('ignores an empty-string pinned currency rather than treating it as a filter', () => {
|
||||
expect(
|
||||
pickInitialPriceId({ prices: STRIPE_ROWS, defaultPriceId: 'gold_usd', pinnedCurrency: '' })
|
||||
).toBe('gold_usd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickInitialPriceId — currency case is normalised on both sides', () => {
|
||||
it('matches an upper-case pin against lower-case price rows', () => {
|
||||
expect(
|
||||
pickInitialPriceId({
|
||||
prices: STRIPE_ROWS,
|
||||
defaultPriceId: 'gold_usd',
|
||||
pinnedCurrency: 'AUD',
|
||||
})
|
||||
).toBe('gold_aud');
|
||||
});
|
||||
|
||||
it('matches a lower-case pin against upper-case price rows', () => {
|
||||
expect(
|
||||
pickInitialPriceId({
|
||||
prices: OTHER_PROVIDER_ROWS,
|
||||
defaultPriceId: 'gold_USD',
|
||||
pinnedCurrency: 'aud',
|
||||
})
|
||||
).toBe('gold_AUD');
|
||||
});
|
||||
|
||||
it('treats the default as already payable when only its case differs from the pin', () => {
|
||||
// Without normalisation on the default-price branch, this falls through to the currency
|
||||
// search and can return a different row — the mutation that a single-sided test misses.
|
||||
expect(
|
||||
pickInitialPriceId({
|
||||
prices: [{ id: 'gold_usd_other', currency: 'usd' }, ...OTHER_PROVIDER_ROWS],
|
||||
defaultPriceId: 'gold_USD',
|
||||
pinnedCurrency: 'usd',
|
||||
})
|
||||
).toBe('gold_USD');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import * as React from 'react';
|
||||
import type { act as actType } from 'react-dom/test-utils';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { useSelectedPriceId } from '~/components/Subscriptions/useSelectedPriceId';
|
||||
|
||||
/**
|
||||
* REGRESSION coverage for the plan card settling on a price the customer cannot be charged.
|
||||
*
|
||||
* Stripe pins a customer to one billing currency and the server substitutes the sibling price
|
||||
* in it before charging. `pickInitialPriceId` already computes the right row — its own suite
|
||||
* is an invariant guard on that pure rule and says so. What is tested HERE is the wiring,
|
||||
* which is where the money leak was: the pin is read off the viewer's subscription, and on
|
||||
* `/pricing` that subscription arrives LATE.
|
||||
*
|
||||
* - `src/pages/pricing/index.tsx` prefetches `subscriptions.getPlans` in
|
||||
* `getServerSideProps`, so the plans are hydrated on first paint.
|
||||
* - `getUserSubscription` is a plain client query (`memberships.util.ts`), so it is
|
||||
* `undefined` on that first client render.
|
||||
* - `MembershipPlans` gates the grid on `productsLoading` alone, so the card mounts in that
|
||||
* window.
|
||||
*
|
||||
* Seed the selection into a `useState` initializer and it is computed exactly once, in the
|
||||
* only render where the pin is unknowable, and never revisited — the card shows the USD
|
||||
* amount for the whole page life while `subscriptions.update` charges the AUD sibling
|
||||
* immediately (`billing_cycle_anchor: 'now'`), and `MembershipUpgradeModal` displays no
|
||||
* amount in between. The first test below is red against that wiring and green against the
|
||||
* derived one.
|
||||
*/
|
||||
|
||||
// React 18.3 exposes `act` on the `react` export, but our @types/react (18.0.14) predates
|
||||
// that typing. Use the runtime `React.act` and borrow the correctly-typed signature.
|
||||
const act = (React as unknown as { act: typeof actType }).act;
|
||||
|
||||
function renderHook<T>(useCb: () => T) {
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
const ref: { current: T | undefined } = { current: undefined };
|
||||
function Probe() {
|
||||
ref.current = useCb();
|
||||
return null;
|
||||
}
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
return {
|
||||
result: ref,
|
||||
rerender: () => act(() => root.render(React.createElement(Probe))),
|
||||
unmount: () => act(() => root.unmount()),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
/**
|
||||
* One membership product's sibling prices, as `subscriptions.getPlans` ships them. USD first,
|
||||
* so an implementation that simply returns the head of the list is indistinguishable from one
|
||||
* that returns the USD default — and both are caught by the pinned-currency assertions.
|
||||
*/
|
||||
const GOLD_PRICES = [
|
||||
{ id: 'fixture_gold_first', currency: 'usd' },
|
||||
{ id: 'fixture_gold_second', currency: 'aud' },
|
||||
{ id: 'fixture_gold_third', currency: 'eur' },
|
||||
];
|
||||
const GOLD_USD = 'fixture_gold_first';
|
||||
const GOLD_AUD = 'fixture_gold_second';
|
||||
const GOLD_EUR = 'fixture_gold_third';
|
||||
|
||||
describe('useSelectedPriceId — a pin that arrives after mount', () => {
|
||||
it('follows the pinned currency once the subscription resolves', () => {
|
||||
// The card mounts with the plans hydrated and the subscription query still in flight.
|
||||
let pinnedCurrency: string | undefined = undefined;
|
||||
const { result, rerender, unmount } = renderHook(() =>
|
||||
useSelectedPriceId({ prices: GOLD_PRICES, defaultPriceId: GOLD_USD, pinnedCurrency })
|
||||
);
|
||||
|
||||
// Nothing is known about the pin yet, so the product default is the honest answer.
|
||||
expect(result.current?.[0]).toBe(GOLD_USD);
|
||||
|
||||
// getUserSubscription answers: this member is billed in AUD.
|
||||
pinnedCurrency = 'aud';
|
||||
rerender();
|
||||
|
||||
// THE REGRESSION. Computed in a `useState` initializer this is still the USD row, so the
|
||||
// card shows an amount that is never the one charged.
|
||||
expect(result.current?.[0]).toBe(GOLD_AUD);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('follows a defaultPriceId that arrives with the same subscription', () => {
|
||||
// On the card for the plan the member already holds, `defaultPriceId` is derived from
|
||||
// `subscription.price.id` — so it is `undefined`-shaped in exactly the same window, and a
|
||||
// mount-time-only read pins the card to the product default instead of the member's own
|
||||
// price row.
|
||||
let defaultPriceId = GOLD_USD;
|
||||
let pinnedCurrency: string | undefined = undefined;
|
||||
const { result, rerender, unmount } = renderHook(() =>
|
||||
useSelectedPriceId({ prices: GOLD_PRICES, defaultPriceId, pinnedCurrency })
|
||||
);
|
||||
|
||||
expect(result.current?.[0]).toBe(GOLD_USD);
|
||||
|
||||
defaultPriceId = GOLD_EUR;
|
||||
pinnedCurrency = 'eur';
|
||||
rerender();
|
||||
|
||||
expect(result.current?.[0]).toBe(GOLD_EUR);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSelectedPriceId — a pin already known at mount', () => {
|
||||
it('picks the pinned row on the FIRST render, with no second pass', () => {
|
||||
// The client-side navigation path: `getUserSubscription` is already in the react-query
|
||||
// cache, so the pin is present before the card mounts. This must not regress into a
|
||||
// render-then-correct flash.
|
||||
const renders: (string | null)[] = [];
|
||||
const { rerender, unmount } = renderHook(() => {
|
||||
const [priceId] = useSelectedPriceId({
|
||||
prices: GOLD_PRICES,
|
||||
defaultPriceId: GOLD_USD,
|
||||
pinnedCurrency: 'aud',
|
||||
});
|
||||
renders.push(priceId);
|
||||
return priceId;
|
||||
});
|
||||
|
||||
expect(renders[0]).toBe(GOLD_AUD);
|
||||
|
||||
rerender();
|
||||
// Every render agrees: no value other than the pinned row is ever shown.
|
||||
expect(new Set(renders)).toEqual(new Set([GOLD_AUD]));
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('leaves an unpinned card on the product default', () => {
|
||||
// Anonymous visitors and members with no Stripe history: the pre-change behaviour, which
|
||||
// the fix must not disturb.
|
||||
const { result, rerender, unmount } = renderHook(() =>
|
||||
useSelectedPriceId({ prices: GOLD_PRICES, defaultPriceId: GOLD_USD })
|
||||
);
|
||||
|
||||
expect(result.current?.[0]).toBe(GOLD_USD);
|
||||
rerender();
|
||||
expect(result.current?.[0]).toBe(GOLD_USD);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSelectedPriceId — an explicit choice from the currency picker', () => {
|
||||
it('survives a pin arriving afterwards', () => {
|
||||
// The hazard a `useEffect` resync or a remount-on-key would introduce: recomputing the
|
||||
// selection would silently undo a choice the user had already made in the picker.
|
||||
let pinnedCurrency: string | undefined = undefined;
|
||||
const { result, rerender, unmount } = renderHook(() =>
|
||||
useSelectedPriceId({ prices: GOLD_PRICES, defaultPriceId: GOLD_USD, pinnedCurrency })
|
||||
);
|
||||
|
||||
act(() => result.current?.[1](GOLD_EUR));
|
||||
expect(result.current?.[0]).toBe(GOLD_EUR);
|
||||
|
||||
pinnedCurrency = 'aud';
|
||||
rerender();
|
||||
|
||||
expect(result.current?.[0]).toBe(GOLD_EUR);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('is discarded when it is no longer one of the product prices', () => {
|
||||
// Toggling the billing interval swaps the product's price rows under the card.
|
||||
let prices = GOLD_PRICES;
|
||||
const { result, rerender, unmount } = renderHook(() =>
|
||||
useSelectedPriceId({ prices, defaultPriceId: GOLD_USD, pinnedCurrency: 'aud' })
|
||||
);
|
||||
|
||||
act(() => result.current?.[1](GOLD_EUR));
|
||||
expect(result.current?.[0]).toBe(GOLD_EUR);
|
||||
|
||||
prices = [
|
||||
{ id: 'fixture_gold_annual_first', currency: 'usd' },
|
||||
{ id: 'fixture_gold_annual_second', currency: 'aud' },
|
||||
];
|
||||
rerender();
|
||||
|
||||
expect(result.current?.[0]).toBe('fixture_gold_annual_second');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
type SelectablePrice = { id: string; currency: string };
|
||||
|
||||
/**
|
||||
* Which of a membership product's sibling prices a plan card should preselect.
|
||||
*
|
||||
* A membership Product carries one active price per supported currency, and the card lets
|
||||
* the user switch between them. The default is `product.defaultPriceId` (USD) — but Stripe
|
||||
* pins a customer to ONE billing currency the first time they are invoiced and that pin is
|
||||
* immutable, so for a customer already pinned elsewhere every other currency on the card is
|
||||
* an amount they cannot be charged.
|
||||
*
|
||||
* The server substitutes the correct sibling before it calls Stripe, so a purchase succeeds
|
||||
* either way. This exists so the figure on the card is the figure that gets charged —
|
||||
* which matters most on the plan-change path, where the charge happens immediately with no
|
||||
* Stripe-hosted confirmation screen in between.
|
||||
*
|
||||
* The only pinned currency we can know client-side is the one on an existing subscription:
|
||||
* Stripe accepted that price, so by its own rule it is payable in the customer's currency.
|
||||
* `customer.currency` itself is a Stripe-side fact with no representation in our database.
|
||||
*
|
||||
* Case is normalised on both sides. Stripe's API returns lower-case codes and our Stripe
|
||||
* rows follow it, but this component is provider-generic and the other payment provider's
|
||||
* rows carry the same currencies in upper case, so a case-sensitive comparison silently
|
||||
* matches nothing for those products.
|
||||
*/
|
||||
export function pickInitialPriceId<T extends SelectablePrice>({
|
||||
prices,
|
||||
defaultPriceId,
|
||||
pinnedCurrency,
|
||||
}: {
|
||||
prices: T[];
|
||||
defaultPriceId?: string | null;
|
||||
pinnedCurrency?: string | null;
|
||||
}): string | null {
|
||||
if (prices.length === 0) return null;
|
||||
|
||||
const pinned = pinnedCurrency ? pinnedCurrency.toLowerCase() : undefined;
|
||||
const defaultPrice = prices.find((p) => p.id === defaultPriceId);
|
||||
|
||||
// The default wins whenever it is payable, so an active plan keeps its own price rather
|
||||
// than being re-matched to some other row in the same currency.
|
||||
if (defaultPrice && (!pinned || defaultPrice.currency.toLowerCase() === pinned)) {
|
||||
return defaultPrice.id;
|
||||
}
|
||||
|
||||
if (pinned) {
|
||||
const inPinnedCurrency = prices.find((p) => p.currency.toLowerCase() === pinned);
|
||||
if (inPinnedCurrency) return inPinnedCurrency.id;
|
||||
}
|
||||
|
||||
// No pin, or nothing sold in it — fall back exactly as before: the product default, then
|
||||
// USD, then whatever is first. The server decides what is actually chargeable.
|
||||
return (
|
||||
defaultPrice?.id ?? prices.find((p) => p.currency.toLowerCase() === 'usd')?.id ?? prices[0].id
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { pickInitialPriceId } from '~/components/Subscriptions/pickInitialPriceId';
|
||||
|
||||
type SelectablePrice = { id: string; currency: string };
|
||||
|
||||
/**
|
||||
* Which of a membership product's sibling prices a plan card is currently showing, and the
|
||||
* setter its currency picker writes to.
|
||||
*
|
||||
* `pickInitialPriceId` answers "which row should be preselected". The obvious wiring —
|
||||
* seeding a `useState` with it — is wrong here, because one of its inputs ARRIVES LATE.
|
||||
* `pinnedCurrency` comes from the viewer's existing subscription, which `/pricing` fetches
|
||||
* as a plain client query while the plans themselves are prefetched in `getServerSideProps`.
|
||||
* So on a hard load the card mounts with the plans already hydrated and the subscription
|
||||
* still `undefined`, a `useState` initializer runs exactly once in that state, and the pin
|
||||
* can never be applied: the card settles on the USD default and stays there for the whole
|
||||
* page life, while the rest of the card re-renders around it and starts offering "Upgrade".
|
||||
* The server then substitutes the pinned-currency sibling and charges an amount the card
|
||||
* never displayed.
|
||||
*
|
||||
* So the preselection is DERIVED on every render instead, and state holds only the one thing
|
||||
* that genuinely belongs to the component: an explicit choice the user made in the picker.
|
||||
* Three properties matter, and each is pinned by a test:
|
||||
*
|
||||
* - late inputs land. The moment `pinnedCurrency` (or `defaultPriceId`) resolves, the shown
|
||||
* price follows, in the same render as the rest of the card.
|
||||
* - a warm cache is unchanged. On a client-side navigation the subscription is already in
|
||||
* the react-query cache, so the first render computes the pinned row directly — no
|
||||
* spinner, no remount, no second pass that could flash a different amount.
|
||||
* - an explicit choice sticks. Once the user picks a currency it is never recomputed away,
|
||||
* which is exactly what an effect-based or key-based resync would risk.
|
||||
*
|
||||
* A selection that is no longer among `prices` is discarded rather than rendered as a blank
|
||||
* picker — the product's own prices change when the billing interval toggles.
|
||||
*/
|
||||
export function useSelectedPriceId<T extends SelectablePrice>({
|
||||
prices,
|
||||
defaultPriceId,
|
||||
pinnedCurrency,
|
||||
}: {
|
||||
prices: T[];
|
||||
defaultPriceId?: string | null;
|
||||
pinnedCurrency?: string | null;
|
||||
}): readonly [string | null, (priceId: string | null) => void] {
|
||||
const [chosenPriceId, setChosenPriceId] = useState<string | null>(null);
|
||||
|
||||
const chosen = prices.find((p) => p.id === chosenPriceId);
|
||||
const priceId = chosen?.id ?? pickInitialPriceId({ prices, defaultPriceId, pinnedCurrency });
|
||||
|
||||
return [priceId, setChosenPriceId] as const;
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TRPCError } from '@trpc/server';
|
||||
import type * as GetServerStripeModule from '~/server/utils/get-server-stripe';
|
||||
import type * as SessionInvalidationModule from '~/server/auth/session-invalidation';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
|
||||
/**
|
||||
* Stripe pins a customer to ONE billing currency the first time they are invoiced
|
||||
* (`customer.currency`), and it is immutable. Every later subscription price for that
|
||||
* customer must be payable in it, or the call is rejected with
|
||||
*
|
||||
* The price specified only supports `usd`. This doesn't match the expected currency: `aud`.
|
||||
*
|
||||
* That is a raw `StripeInvalidRequestError` thrown out of `checkout.sessions.create` /
|
||||
* `subscriptions.update`, so it reached the client as a tRPC INTERNAL_SERVER_ERROR (500).
|
||||
*
|
||||
* The membership IS purchasable in that currency: every membership Product carries an active
|
||||
* monthly sibling Price per supported currency, and the pricing page ships the whole set to
|
||||
* the browser. What the browser cannot know is which currency the customer is pinned to —
|
||||
* `customer.currency` is a Stripe-side fact with no representation in our database. So the
|
||||
* fix is a server-side substitution: resolve the sibling Price in the pinned currency and
|
||||
* charge that. These tests pin the substitution on BOTH paths (new Checkout session and
|
||||
* in-place plan change), the scoping of the lookup, the case normalisation, and the two
|
||||
* cases where no single correct answer exists and a typed error is the honest answer.
|
||||
*/
|
||||
|
||||
const { mockGetServerStripe, mockRefreshSession } = vi.hoisted(() => ({
|
||||
mockGetServerStripe: vi.fn(),
|
||||
mockRefreshSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Surgical: spread the original and override the one symbol, so a module that later gains
|
||||
// an export does not turn this file into a collection failure reported as "no tests".
|
||||
vi.mock('~/server/utils/get-server-stripe', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GetServerStripeModule>()),
|
||||
getServerStripe: (...args: unknown[]) => mockGetServerStripe(...args),
|
||||
}));
|
||||
vi.mock('~/server/auth/session-invalidation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof SessionInvalidationModule>()),
|
||||
refreshSession: (...args: unknown[]) => mockRefreshSession(...args),
|
||||
}));
|
||||
// The block-attribution validator has its own suite and pulls in the block registry. No
|
||||
// test here passes a `blockAttribution`, so the stub is only keeping that import graph out.
|
||||
vi.mock('~/server/services/blocks/attribution-validator.service', () => ({
|
||||
validateBuzzPurchaseAttribution: ({ metadata }: { metadata: Record<string, unknown> }) =>
|
||||
Promise.resolve(metadata),
|
||||
}));
|
||||
// Imported by stripe.service (getOrCreateVault) and unreachable from this function; mocked
|
||||
// to cut the transitive common.service → caches → selectors → Prisma.validator chain.
|
||||
vi.mock('~/server/services/vault.service', () => ({
|
||||
getOrCreateVault: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createSubscribeSession } from '../stripe.service';
|
||||
|
||||
const USER = { id: 100, email: 'buyer@example.com' };
|
||||
// Deliberately not spelled with Stripe's real id prefixes where it would matter: nothing
|
||||
// here parses a prefix, and secret scans treat those shapes as live identifiers.
|
||||
const CUSTOMER_ID = 'customer_fixture';
|
||||
const GOLD_PRODUCT_ID = 'product_fixture_gold';
|
||||
const BRONZE_PRODUCT_ID = 'product_fixture_bronze';
|
||||
const GOLD_PRICE_USD = 'fixture_gold_usd';
|
||||
const GOLD_PRICE_AUD = 'fixture_gold_aud';
|
||||
const GOLD_PRICE_AUD_QUARTERLY = 'fixture_gold_aud_quarterly';
|
||||
const BRONZE_PRICE_USD = 'fixture_bronze_usd';
|
||||
|
||||
let mockCheckoutSessionsCreate: ReturnType<typeof vi.fn>;
|
||||
let mockSubscriptionsUpdate: ReturnType<typeof vi.fn>;
|
||||
let mockSubscriptionsList: ReturnType<typeof vi.fn>;
|
||||
let mockCustomersRetrieve: ReturnType<typeof vi.fn>;
|
||||
let mockPricesRetrieve: ReturnType<typeof vi.fn>;
|
||||
let mockPricesList: ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* @param customerCurrency what Stripe has pinned the customer to — `null` for a customer
|
||||
* that has never been invoiced, which is the unconstrained case.
|
||||
* @param priceCurrency the currency of the Price the caller asked for.
|
||||
* @param currencyOptions extra currencies the requested Price itself can be paid in.
|
||||
* @param siblings what `prices.list` returns for the pinned currency.
|
||||
* @param withActiveBronzeSubscription when set, the customer already holds a plan, so the
|
||||
* call takes the in-place `subscriptions.update` path instead of Checkout.
|
||||
*/
|
||||
function stubStripe({
|
||||
customerCurrency,
|
||||
priceCurrency = 'usd',
|
||||
currencyOptions,
|
||||
siblings = [],
|
||||
withActiveBronzeSubscription = false,
|
||||
}: {
|
||||
customerCurrency: string | null;
|
||||
priceCurrency?: string;
|
||||
currencyOptions?: Record<string, unknown>;
|
||||
siblings?: Array<Record<string, unknown>>;
|
||||
withActiveBronzeSubscription?: boolean;
|
||||
}) {
|
||||
mockSubscriptionsList.mockResolvedValue({
|
||||
data: withActiveBronzeSubscription
|
||||
? [
|
||||
{
|
||||
id: 'subscription_fixture',
|
||||
status: 'active',
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
id: 'subscription_item_fixture',
|
||||
subscription: 'subscription_fixture',
|
||||
price: { id: BRONZE_PRICE_USD, product: BRONZE_PRODUCT_ID },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
mockCustomersRetrieve.mockResolvedValue({
|
||||
id: CUSTOMER_ID,
|
||||
// No `deleted` key: a real Stripe.Customer does not carry one — only DeletedCustomer
|
||||
// does, as `true`. The service reads it for falsiness, so absent is the truthful shape.
|
||||
currency: customerCurrency,
|
||||
// Set so the plan-change path never reaches the payment-method lookup.
|
||||
default_source: 'card_fixture',
|
||||
});
|
||||
mockPricesRetrieve.mockResolvedValue({
|
||||
id: GOLD_PRICE_USD,
|
||||
product: GOLD_PRODUCT_ID,
|
||||
currency: priceCurrency,
|
||||
// Monthly, billed every month — the shape every membership price has. Both fields are
|
||||
// load-bearing: the lookup filters on `interval` at the API and on `interval_count`
|
||||
// afterwards, because Stripe's list endpoint has no filter for the latter.
|
||||
recurring: { interval: 'month', interval_count: 1 },
|
||||
...(currencyOptions ? { currency_options: currencyOptions } : {}),
|
||||
});
|
||||
mockPricesList.mockResolvedValue({ data: siblings });
|
||||
}
|
||||
|
||||
/** The AUD sibling of the Gold monthly price, at a genuinely different amount. */
|
||||
const AUD_SIBLING = {
|
||||
id: GOLD_PRICE_AUD,
|
||||
product: GOLD_PRODUCT_ID,
|
||||
currency: 'aud',
|
||||
unit_amount: 8000,
|
||||
recurring: { interval: 'month', interval_count: 1 },
|
||||
};
|
||||
|
||||
async function subscribe() {
|
||||
return createSubscribeSession({
|
||||
priceId: GOLD_PRICE_USD,
|
||||
customerId: CUSTOMER_ID,
|
||||
user: USER,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockCheckoutSessionsCreate = vi.fn().mockResolvedValue({
|
||||
id: 'checkout_session_fixture',
|
||||
url: 'https://checkout.example.test/session',
|
||||
});
|
||||
mockSubscriptionsUpdate = vi.fn().mockResolvedValue({});
|
||||
mockSubscriptionsList = vi.fn();
|
||||
mockCustomersRetrieve = vi.fn();
|
||||
mockPricesRetrieve = vi.fn();
|
||||
mockPricesList = vi.fn();
|
||||
|
||||
mockGetServerStripe.mockResolvedValue({
|
||||
checkout: { sessions: { create: mockCheckoutSessionsCreate } },
|
||||
subscriptions: {
|
||||
list: mockSubscriptionsList,
|
||||
update: mockSubscriptionsUpdate,
|
||||
resume: vi.fn(),
|
||||
},
|
||||
customers: { retrieve: mockCustomersRetrieve, update: vi.fn() },
|
||||
prices: { retrieve: mockPricesRetrieve, list: mockPricesList },
|
||||
paymentMethods: { list: vi.fn().mockResolvedValue({ data: [] }) },
|
||||
coupons: { create: vi.fn() },
|
||||
});
|
||||
mockRefreshSession.mockResolvedValue(undefined);
|
||||
|
||||
// env.TIER_METADATA_KEY defaults to 'tier'; the filter keys off its presence.
|
||||
dbMock.dbRead.product.findMany.mockResolvedValue([
|
||||
{ id: GOLD_PRODUCT_ID, metadata: { tier: 'gold' } },
|
||||
{ id: BRONZE_PRODUCT_ID, metadata: { tier: 'bronze' } },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('createSubscribeSession — charges the sibling price in the pinned currency', () => {
|
||||
it('sends Checkout the AUD price, not the USD one the caller asked for', async () => {
|
||||
// The whole point of the change. Before it, this call handed Stripe the USD price for a
|
||||
// customer Stripe will only bill in AUD, and the rejection came back as a 500.
|
||||
stubStripe({ customerCurrency: 'aud', siblings: [AUD_SIBLING] });
|
||||
|
||||
await expect(subscribe()).resolves.toMatchObject({ sessionId: 'checkout_session_fixture' });
|
||||
|
||||
expect(mockCheckoutSessionsCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockCheckoutSessionsCreate.mock.calls[0][0].line_items).toEqual([
|
||||
{ price: GOLD_PRICE_AUD, quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('substitutes on the plan-change path too, which charges with no confirmation screen', async () => {
|
||||
// `subscriptions.update` rejects on a currency mismatch exactly as Checkout does, so a
|
||||
// substitution in front of only one of them would still 500 every pinned member's
|
||||
// upgrade — and this is the path that takes the money immediately.
|
||||
stubStripe({
|
||||
customerCurrency: 'aud',
|
||||
siblings: [AUD_SIBLING],
|
||||
withActiveBronzeSubscription: true,
|
||||
});
|
||||
|
||||
await subscribe();
|
||||
|
||||
expect(mockSubscriptionsUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSubscriptionsUpdate.mock.calls[0][1].items).toEqual([
|
||||
{ id: 'subscription_item_fixture', price: GOLD_PRICE_AUD },
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes the lookup to the same product, active, recurring and the same interval', async () => {
|
||||
// Each of these narrows a way the substitute could be the wrong thing to charge: another
|
||||
// tier, a retired price, a one-off, or a yearly plan standing in for a monthly one. The
|
||||
// product filter also keeps the lookup inside Stripe's own catalog — our Price table
|
||||
// holds rows for another payment provider under the same tier names.
|
||||
stubStripe({ customerCurrency: 'aud', siblings: [AUD_SIBLING] });
|
||||
|
||||
await subscribe();
|
||||
|
||||
expect(mockPricesList).toHaveBeenCalledTimes(1);
|
||||
expect(mockPricesList.mock.calls[0][0]).toMatchObject({
|
||||
product: GOLD_PRODUCT_ID,
|
||||
currency: 'aud',
|
||||
active: true,
|
||||
type: 'recurring',
|
||||
recurring: { interval: 'month' },
|
||||
});
|
||||
});
|
||||
|
||||
it('will not substitute a price billed every 3 months for a monthly one', async () => {
|
||||
// `interval_count` is NOT a filter on Stripe's list endpoint, so it has to be applied
|
||||
// after the call. Without that, this quarterly price is the single candidate and gets
|
||||
// charged — 3x the billing period at an amount nobody picked.
|
||||
stubStripe({
|
||||
customerCurrency: 'aud',
|
||||
siblings: [
|
||||
{
|
||||
id: GOLD_PRICE_AUD_QUARTERLY,
|
||||
product: GOLD_PRODUCT_ID,
|
||||
currency: 'aud',
|
||||
unit_amount: 22000,
|
||||
recurring: { interval: 'month', interval_count: 3 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(subscribe()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(mockCheckoutSessionsCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalises case on the customer side, so an upper-case pin still resolves', async () => {
|
||||
// Stripe documents `customer.currency` as lower-case, so this is defence rather than a
|
||||
// contract — but the value is compared against catalog data whose other payment provider
|
||||
// spells the same currencies in upper case, and the list call must be asked in the
|
||||
// currency Stripe expects regardless of what arrived.
|
||||
stubStripe({ customerCurrency: 'AUD', siblings: [AUD_SIBLING] });
|
||||
|
||||
await subscribe();
|
||||
|
||||
expect(mockPricesList.mock.calls[0][0]).toMatchObject({ currency: 'aud' });
|
||||
expect(mockCheckoutSessionsCreate.mock.calls[0][0].line_items).toEqual([
|
||||
{ price: GOLD_PRICE_AUD, quantity: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSubscribeSession — leaves the requested price alone when it is payable', () => {
|
||||
it('proceeds for a customer Stripe has not pinned to a currency yet', async () => {
|
||||
stubStripe({ customerCurrency: null });
|
||||
|
||||
await expect(subscribe()).resolves.toMatchObject({ sessionId: 'checkout_session_fixture' });
|
||||
expect(mockPricesList).not.toHaveBeenCalled();
|
||||
expect(mockCheckoutSessionsCreate.mock.calls[0][0].line_items).toEqual([
|
||||
{ price: GOLD_PRICE_USD, quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('proceeds when the currencies match, comparing case-insensitively on BOTH sides', async () => {
|
||||
// Vary BOTH operands in the one case: with only the customer side varied, dropping
|
||||
// `.toLowerCase()` from the price side survives the whole file, and the comment would be
|
||||
// claiming coverage the body does not provide.
|
||||
stubStripe({ customerCurrency: 'USD', priceCurrency: 'UsD' });
|
||||
|
||||
await expect(subscribe()).resolves.toMatchObject({ sessionId: 'checkout_session_fixture' });
|
||||
expect(mockPricesList).not.toHaveBeenCalled();
|
||||
expect(mockCheckoutSessionsCreate.mock.calls[0][0].line_items).toEqual([
|
||||
{ price: GOLD_PRICE_USD, quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('proceeds when the requested price itself declares the customer currency', async () => {
|
||||
// A multi-currency Price is payable in every currency it declares, so there is nothing
|
||||
// to substitute. Our catalog does not use this today — but Stripe supports it, and
|
||||
// without the check, provisioning `currency_options` on a Price would turn a purchase
|
||||
// that works into a rejection.
|
||||
stubStripe({ customerCurrency: 'aud', currencyOptions: { aud: { unit_amount: 8000 } } });
|
||||
|
||||
await expect(subscribe()).resolves.toMatchObject({ sessionId: 'checkout_session_fixture' });
|
||||
expect(mockPricesList).not.toHaveBeenCalled();
|
||||
expect(mockCheckoutSessionsCreate.mock.calls[0][0].line_items).toEqual([
|
||||
{ price: GOLD_PRICE_USD, quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still substitutes when the price declares OTHER currencies but not the customer’s', async () => {
|
||||
// The half-provisioned Price: somebody adds EUR and GBP to the membership Price and not
|
||||
// AUD. A check that merely noticed `currency_options` EXISTS would wave this straight
|
||||
// into the Stripe 500, while every other test in this file stayed green — the
|
||||
// substitution cases never set the field, and the payable case only sets a MATCHING key.
|
||||
// Pins the key lookup, not the field's presence.
|
||||
stubStripe({
|
||||
customerCurrency: 'aud',
|
||||
currencyOptions: { eur: { unit_amount: 5000 }, gbp: { unit_amount: 4000 } },
|
||||
siblings: [AUD_SIBLING],
|
||||
});
|
||||
|
||||
await subscribe();
|
||||
|
||||
expect(mockCheckoutSessionsCreate.mock.calls[0][0].line_items).toEqual([
|
||||
{ price: GOLD_PRICE_AUD, quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('asks Stripe to expand currency_options, or the branch above can never fire live', async () => {
|
||||
// 🔴 The `currency_options` tests are only meaningful if the real API returns the field.
|
||||
// It is an EXPANDABLE field on Price — like `tiers`, omitted unless requested, which is
|
||||
// why the SDK types it optional while `currency` is not. A mock hands it over regardless,
|
||||
// so without this assertion that branch would be dead in production and every test above
|
||||
// it would still be green: the fixture would encode a shape the real call never produces.
|
||||
stubStripe({ customerCurrency: null });
|
||||
await subscribe();
|
||||
|
||||
expect(mockPricesRetrieve).toHaveBeenCalledWith(
|
||||
GOLD_PRICE_USD,
|
||||
expect.objectContaining({ expand: expect.arrayContaining(['currency_options']) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSubscribeSession — the two cases with no single right answer', () => {
|
||||
// The messages are asserted WHOLE rather than by keyword. These strings are the entire
|
||||
// user-facing output of this failure, and the previous version of this fix shipped a
|
||||
// message that told the customer the membership could not be purchased at all — which was
|
||||
// false. A keyword assertion cannot catch a reword back into a false claim; pinning the
|
||||
// string means a reword has to be deliberate.
|
||||
const NOT_SOLD_MESSAGE =
|
||||
'Your billing account is set up in AUD, and this membership is not currently sold in AUD. ' +
|
||||
"Stripe does not allow an account's billing currency to change once it is set. Please " +
|
||||
'contact support and we can look at the options for your account.';
|
||||
|
||||
const AMBIGUOUS_MESSAGE =
|
||||
'We could not determine the price of this membership in AUD, the currency your billing ' +
|
||||
'account is set up in. Please contact support so we can correct it — you have not been ' +
|
||||
'charged.';
|
||||
|
||||
it('rejects with a typed 4xx when the membership is genuinely not sold in the currency', async () => {
|
||||
stubStripe({ customerCurrency: 'aud', siblings: [] });
|
||||
|
||||
const error = (await subscribe().catch((e) => e)) as TRPCError;
|
||||
|
||||
expect(error.code).toBe('BAD_REQUEST');
|
||||
expect(error.message).toBe(NOT_SOLD_MESSAGE);
|
||||
});
|
||||
|
||||
it('does not claim the membership cannot be purchased, because that was the false premise', async () => {
|
||||
// The refuted version of this fix asserted the purchase was impossible. It is not: the
|
||||
// sibling prices exist, and the tests above charge them. This one exists so the false
|
||||
// sentence cannot come back without a test going red.
|
||||
stubStripe({ customerCurrency: 'aud', siblings: [] });
|
||||
|
||||
const error = (await subscribe().catch((e) => e)) as TRPCError;
|
||||
|
||||
expect(error.message).not.toMatch(/cannot be purchased/i);
|
||||
});
|
||||
|
||||
it('refuses rather than guessing when more than one active price matches', async () => {
|
||||
// Real catalogs grow duplicates. Picking one charges an amount nobody chose, and the
|
||||
// amounts in a duplicated row are not necessarily close to each other.
|
||||
stubStripe({
|
||||
customerCurrency: 'aud',
|
||||
siblings: [
|
||||
AUD_SIBLING,
|
||||
{ ...AUD_SIBLING, id: 'fixture_gold_aud_duplicate', unit_amount: 88000 },
|
||||
],
|
||||
});
|
||||
|
||||
const error = (await subscribe().catch((e) => e)) as TRPCError;
|
||||
|
||||
expect(error.code).toBe('BAD_REQUEST');
|
||||
expect(error.message).toBe(AMBIGUOUS_MESSAGE);
|
||||
});
|
||||
|
||||
it('reaches neither Stripe charging call in either case', async () => {
|
||||
stubStripe({ customerCurrency: 'aud', siblings: [], withActiveBronzeSubscription: true });
|
||||
await subscribe().catch(() => undefined);
|
||||
|
||||
expect(mockCheckoutSessionsCreate).not.toHaveBeenCalled();
|
||||
expect(mockSubscriptionsUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -117,6 +117,110 @@ export async function deriveSubscriptionAttributionMetadata({
|
||||
return encodeAttributionMetadata(derived);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the membership Price this customer can actually be charged.
|
||||
*
|
||||
* Stripe pins a customer to ONE billing currency the first time they are invoiced
|
||||
* (`customer.currency`) and it is immutable thereafter. Every subscription price billed to
|
||||
* that customer has to be payable in it, or Stripe rejects the call with
|
||||
*
|
||||
* The price specified only supports `usd`. This doesn't match the expected currency: `aud`.
|
||||
*
|
||||
* That rejection is a raw throw out of `checkout.sessions.create` / `subscriptions.update`,
|
||||
* so it reached the client as a tRPC INTERNAL_SERVER_ERROR (HTTP 500).
|
||||
*
|
||||
* The membership IS purchasable in that currency — each membership Product carries an
|
||||
* active monthly sibling Price per supported currency, and the pricing page ships the whole
|
||||
* set to the browser (`getPlans` selects `product.prices`). What the client cannot know is
|
||||
* which one the customer is pinned to: `customer.currency` is a Stripe-side fact with no
|
||||
* representation in our database and no endpoint that exposes it. So the substitution
|
||||
* belongs here, where both the customer and the price are already in hand, rather than in
|
||||
* the price picker — and putting it here also covers the plan-change path and any caller
|
||||
* that reaches the procedure without going through the pricing page at all.
|
||||
*
|
||||
* Returns the Price to charge. Throws a typed BAD_REQUEST only in the two cases where no
|
||||
* single correct answer exists; it never guesses an amount.
|
||||
*/
|
||||
async function resolvePriceForCustomerCurrency({
|
||||
stripe,
|
||||
customer,
|
||||
price,
|
||||
}: {
|
||||
stripe: Stripe;
|
||||
customer: Stripe.Customer;
|
||||
price: Stripe.Price;
|
||||
}): Promise<Stripe.Price> {
|
||||
// Null until Stripe has invoiced them — an unpinned customer constrains nothing.
|
||||
const customerCurrency = customer.currency?.toLowerCase();
|
||||
if (!customerCurrency) return price;
|
||||
|
||||
// Lower-cased on both sides. Stripe's API returns lower-case currency codes, but this
|
||||
// value is also compared against data that reaches us from the price picker, and the
|
||||
// Product/Price tables carry upper-case codes for the other payment provider, so a
|
||||
// case-sensitive comparison is one catalog edit away from being wrong in both directions.
|
||||
// `Stripe.Price.currency` is non-optional, so there is no absent-currency case here.
|
||||
if (price.currency.toLowerCase() === customerCurrency) return price;
|
||||
|
||||
// A multi-currency Price is payable in every currency it declares, so there is nothing to
|
||||
// substitute. Our membership catalog does not use this — it uses sibling Prices, which is
|
||||
// what the lookup below is for — but Stripe supports both, and without this check
|
||||
// provisioning `currency_options` on a Price would turn a purchase that works today into
|
||||
// the error below. `currency_options` is an expandable field and is NOT returned by
|
||||
// default, hence the expand at the call site; without it this branch is dead.
|
||||
if (price.currency_options?.[customerCurrency]) return price;
|
||||
|
||||
const productId = typeof price.product === 'string' ? price.product : price.product.id;
|
||||
const interval = price.recurring?.interval;
|
||||
|
||||
// Every membership price is recurring (the caller has already checked the price belongs to
|
||||
// a membership product), so this is a shape assertion rather than a reachable branch.
|
||||
if (!interval) {
|
||||
throw throwBadRequestError(
|
||||
`This membership cannot be billed in ${customerCurrency.toUpperCase()}, the currency your billing account is set up in.`
|
||||
);
|
||||
}
|
||||
|
||||
// Scoped to the SAME product, so the substitute is the same membership tier. `product` is
|
||||
// read off the Stripe Price we just retrieved, which also keeps the lookup inside Stripe's
|
||||
// catalog — the Product/Price tables hold rows for other payment providers under the same
|
||||
// tier names, and one of those price ids handed to Stripe would be a different failure.
|
||||
const { data: siblings } = await stripe.prices.list({
|
||||
product: productId,
|
||||
currency: customerCurrency,
|
||||
active: true,
|
||||
type: 'recurring',
|
||||
recurring: { interval },
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// `interval_count` is not a list filter, so it is applied here. Without it a monthly
|
||||
// membership could be substituted by a price billed every 3 months at the same interval.
|
||||
const intervalCount = price.recurring?.interval_count ?? 1;
|
||||
const candidates = siblings.filter((p) => (p.recurring?.interval_count ?? 1) === intervalCount);
|
||||
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
if (candidates.length === 0) {
|
||||
// The genuine fallback: the membership really is not sold in this currency. Says that,
|
||||
// and does not claim the account is unusable — the other tiers may well be sold in it.
|
||||
throw throwBadRequestError(
|
||||
`Your billing account is set up in ${customerCurrency.toUpperCase()}, and this ` +
|
||||
`membership is not currently sold in ${customerCurrency.toUpperCase()}. Stripe does ` +
|
||||
`not allow an account's billing currency to change once it is set. Please contact ` +
|
||||
`support and we can look at the options for your account.`
|
||||
);
|
||||
}
|
||||
|
||||
// More than one active price matches. Picking one would charge an amount nobody chose, and
|
||||
// the amounts in a duplicated row are not necessarily close — so refuse instead. This is a
|
||||
// catalog problem support can actually get fixed, unlike the currency pin.
|
||||
throw throwBadRequestError(
|
||||
`We could not determine the price of this membership in ${customerCurrency.toUpperCase()}, ` +
|
||||
`the currency your billing account is set up in. Please contact support so we can ` +
|
||||
`correct it — you have not been charged.`
|
||||
);
|
||||
}
|
||||
|
||||
export const createSubscribeSession = async ({
|
||||
priceId,
|
||||
refCode,
|
||||
@@ -157,12 +261,27 @@ export const createSubscribeSession = async ({
|
||||
throw throwBadRequestError(`Could not find customer with id: ${customerId}`);
|
||||
}
|
||||
|
||||
const price = await stripe.prices.retrieve(priceId);
|
||||
// `currency_options` is an expandable field and is NOT returned by default, so without
|
||||
// this expand the multi-currency branch of resolvePriceForCustomerCurrency is dead and a
|
||||
// Price that Stripe would happily charge in the customer's currency gets substituted (or
|
||||
// rejected) anyway. Expanding costs no extra round trip, only a larger response body.
|
||||
const requestedPrice = await stripe.prices.retrieve(priceId, { expand: ['currency_options'] });
|
||||
|
||||
if (!price || !membershipProducts.find((x) => x.id === (price.product as string))) {
|
||||
if (
|
||||
!requestedPrice ||
|
||||
!membershipProducts.find((x) => x.id === (requestedPrice.product as string))
|
||||
) {
|
||||
throw throwNotFoundError(`The product you are trying to purchase does not exists`);
|
||||
}
|
||||
|
||||
// Everything downstream charges `price`, never the requested id: the customer may be
|
||||
// pinned to a billing currency the requested Price is not sold in, in which case this is
|
||||
// the same membership's sibling Price in that currency. Resolved once, ahead of BOTH
|
||||
// price-carrying Stripe calls — the in-place `subscriptions.update` plan change rejects on
|
||||
// a currency mismatch exactly as `checkout.sessions.create` does, so substituting in front
|
||||
// of only one of them would still 500 every pinned member's upgrade.
|
||||
const price = await resolvePriceForCustomerCurrency({ stripe, customer, price: requestedPrice });
|
||||
|
||||
const activeSubscription = subscriptions.find((x) => x.status !== 'canceled');
|
||||
const subscriptionItem = activeSubscription?.items.data.find((d) =>
|
||||
membershipProducts.some((p) => p.id === (d.price.product as string))
|
||||
@@ -304,10 +423,12 @@ export const createSubscribeSession = async ({
|
||||
}
|
||||
}
|
||||
|
||||
// array of items we are charging the customer
|
||||
// array of items we are charging the customer. `price.id`, NOT the requested `priceId` —
|
||||
// they differ whenever the customer is pinned to a currency the requested Price is not
|
||||
// sold in, and Checkout rejects a mismatched currency exactly as the plan-change path does.
|
||||
const lineItems = [
|
||||
{
|
||||
price: priceId,
|
||||
price: price.id,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user