feat(subscriptions): add global coupon support for checkout sessions (#558)

This commit is contained in:
Corentin Thomasset
2025-10-16 15:36:42 +02:00
committed by GitHub
parent f66a9f5d1b
commit df75e5accb
3 changed files with 93 additions and 14 deletions

View File

@@ -9,6 +9,14 @@ import { Button } from '@/modules/ui/components/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
import { getCheckoutUrl } from '../subscriptions.services';
// Hardcoded global reduction configuration, will be replaced by a dynamic configuration later
const globalReduction = {
enabled: true,
multiplier: 0.5,
// 31 december 2025 23h59 Paris time
untilDate: new Date('2025-12-31T22:59:59Z'),
};
type BillingInterval = 'monthly' | 'annual';
type PlanCardProps = {
@@ -70,24 +78,45 @@ const PlanCard: Component<PlanCardProps> = (props) => {
setIsUpgradeLoading(false);
};
const getIsReductionActive = ({ now = new Date() }: { now?: Date } = {}) => globalReduction.enabled && now < globalReduction.untilDate;
const getReductionMultiplier = ({ now = new Date() }: { now?: Date } = {}) => getIsReductionActive({ now }) ? globalReduction.multiplier : 1;
const getMonthlyPrice = ({ now = new Date() }: { now?: Date } = {}) => {
const multiplier = getReductionMultiplier({ now });
const basePrice = props.billingInterval === 'annual' ? props.annualPrice / 12 : props.monthlyPrice;
return Math.round(100 * basePrice * multiplier) / 100;
};
const getAnnualPrice = () => {
const multiplier = getReductionMultiplier();
return Math.round(100 * props.annualPrice * multiplier) / 100;
};
return (
<div class="border rounded-xl">
<div class="p-6">
<div class="text-sm font-medium text-muted-foreground flex items-center gap-2 justify-between">
<div class="text-sm font-medium text-muted-foreground flex items-center gap-2 justify-between mb-1">
<span class="min-h-24px">{props.name}</span>
{props.isCurrent && <span class="text-xs font-medium text-muted-foreground bg-muted rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.current-plan')}</span>}
{props.isRecommended && <div class="text-xs font-medium text-primary bg-primary/10 rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.recommended')}</div>}
{getIsReductionActive() && props.annualPrice > 0 && <div class="text-xs font-medium text-primary bg-primary/10 rounded-md px-2 py-1">{`-${100 * (1 - getReductionMultiplier())}%`}</div>}
</div>
<div class="text-3xl font-bold mt-1 flex items-baseline gap-1">
$
{props.billingInterval === 'annual' ? Math.round(100 * props.annualPrice / 12) / 100 : props.monthlyPrice}
<span class="text-sm font-normal text-muted-foreground">{t('subscriptions.upgrade-dialog.per-month')}</span>
{getIsReductionActive() && props.annualPrice > 0 && (
<span class="text-lg text-muted-foreground relative after:(content-[''] absolute left--5px right--5px top-1/2 h-2px bg-muted-foreground/40 rounded-full -rotate-12 origin-center)">{`$${(props.billingInterval === 'annual' ? props.annualPrice / 12 : props.monthlyPrice)}`}</span>
)}
<div class="flex items-baseline gap-1">
<span class="text-4xl font-semibold">{`$${getMonthlyPrice()}`}</span>
<span class="text-sm text-muted-foreground">{t('subscriptions.upgrade-dialog.per-month')}</span>
</div>
<div class="overflow-hidden transition-all duration-300" style={{ 'max-height': props.billingInterval === 'annual' ? '24px' : '0px', 'opacity': props.billingInterval === 'annual' ? '1' : '0' }}>
<span class="text-xs text-muted-foreground">{t('subscriptions.upgrade-dialog.billed-annually', { price: props.annualPrice })}</span>
</div>
{
props.annualPrice > 0 && (
<div class="overflow-hidden transition-all duration-300" style={{ 'max-height': props.billingInterval === 'annual' ? '24px' : '0px', 'opacity': props.billingInterval === 'annual' ? '1' : '0' }}>
<span class="text-xs text-muted-foreground">{t('subscriptions.upgrade-dialog.billed-annually', { price: getAnnualPrice() })}</span>
</div>
)
}
<hr class="my-6" />
@@ -211,11 +240,10 @@ export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
<Button
size="sm"
variant="ghost"
class={cn('text-sm pr-1.5', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
onClick={() => setBillingInterval('annual')}
>
{t('subscriptions.billing-interval.annual')}
<span class="ml-2 text-xs text-muted-foreground rounded bg-primary/10 text-primary px-1 py-0.5">-20%</span>
</Button>
</div>
</div>

View File

@@ -14,4 +14,10 @@ export const subscriptionsConfig = {
default: 'change-me',
env: 'STRIPE_WEBHOOK_SECRET',
},
globalCouponId: {
doc: 'The Stripe coupon ID to apply globally for launch promotions',
schema: z.string().optional(),
default: undefined,
env: 'GLOBAL_COUPON_ID',
},
} as const satisfies ConfigDefinition;

View File

@@ -1,8 +1,11 @@
import type { Logger } from '@crowlog/logger';
import type { Buffer } from 'node:buffer';
import type { Config } from '../config/config.types';
import { buildUrl, injectArguments } from '@corentinth/chisels';
import { buildUrl, injectArguments, safely } from '@corentinth/chisels';
import Stripe from 'stripe';
import { getClientBaseUrl } from '../config/config.models';
import { createLogger } from '../shared/logger/logger';
import { isNil } from '../shared/utils';
export type SubscriptionsServices = ReturnType<typeof createSubscriptionsServices>;
@@ -16,6 +19,7 @@ export function createSubscriptionsServices({ config }: { config: Config }) {
parseWebhookEvent,
getCustomerPortalUrl,
getCheckoutSession,
getCoupon,
},
{ stripeClient, config },
);
@@ -53,6 +57,16 @@ export async function createCheckoutUrl({
const successUrl = buildUrl({ baseUrl: clientBaseUrl, path: '/checkout-success?sessionId={CHECKOUT_SESSION_ID}' });
const cancelUrl = buildUrl({ baseUrl: clientBaseUrl, path: '/checkout-cancel' });
const { globalCouponId } = config.subscriptions;
const { coupon } = await getCoupon({ stripeClient, couponId: globalCouponId });
// If there's no coupon or if the coupon is invalid (expired), we just don't apply any discount but allow promotion codes
// to be used at checkout, can't do both at the same time
const discountDetails = isNil(coupon)
? { allow_promotion_codes: true }
: { discounts: [{ coupon: coupon.id }] };
const session = await stripeClient.checkout.sessions.create({
customer: customerId,
payment_method_types: ['card'],
@@ -66,7 +80,6 @@ export async function createCheckoutUrl({
},
],
mode: 'subscription',
allow_promotion_codes: true,
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
@@ -74,6 +87,7 @@ export async function createCheckoutUrl({
organizationId,
},
},
...discountDetails,
});
return { checkoutUrl: session.url };
@@ -111,3 +125,34 @@ async function getCheckoutSession({ stripeClient, sessionId }: { stripeClient: S
return { checkoutSession };
}
async function getCoupon({ stripeClient, couponId, logger = createLogger({ namespace: 'subscriptions:services:getCoupon' }) }: { stripeClient: Stripe; couponId?: string; logger?: Logger }) {
if (isNil(couponId)) {
return { coupon: null };
}
const [coupon, error] = await safely(stripeClient.coupons.retrieve(couponId));
if (!isNil(error)) {
logger.error({ error }, 'Error while retrieving coupon');
return { coupon: null };
}
if (isNil(coupon)) {
logger.error({ couponId }, 'Failed to retrieve coupon');
return { coupon: null };
}
if (!coupon.valid) {
logger.warn({ couponId, couponName: coupon.name }, 'Coupon is not valid');
return { coupon: null };
}
return {
coupon: {
id: coupon.id,
name: coupon.name ?? undefined,
percentOff: coupon.percent_off ?? undefined,
},
};
}