mirror of
https://github.com/papra-hq/papra.git
synced 2026-01-06 16:33:29 -06:00
feat(subscriptions): add global coupon support for checkout sessions (#558)
This commit is contained in:
committed by
GitHub
parent
f66a9f5d1b
commit
df75e5accb
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user