feat(billing): Stripe + abbonamento Pro con trial 30gg (ADR 0004)

Chiude la decisione "payment provider" aperta in CLAUDE.md.

**Modello**: free 30gg post-signup (no carta richiesta) → Pro mensile €9,90 / annuale €89. Allinea il paywall al confine fra fase INTENSIVE e TRANSITION (PRD §5.1), quando l'utente ha già visto i primi risultati. Dopo la scadenza l'app non si "spegne": storico restano consultabili (sola lettura), ma generazione piani / nuove pesate / digiuni / foto / export richiedono abbonamento attivo.

**Schema**: nuova tabella `subscriptions` (1:1 con users) con stati TRIALING/ACTIVE/PAST_DUE/CANCEL_AT_PERIOD_END/CANCELED/EXPIRED + `billing_webhook_events` per idempotenza dei retry Stripe.

**Backend** (`apps/api/src/modules/billing/`):
- `GET /me/billing/status` — snapshot + derived (kind, isPro, trialDaysRemaining)
- `POST /me/billing/checkout` — crea Stripe Checkout Session (subscription mode + Stripe Tax + tax_id_collection)
- `POST /me/billing/portal` — Customer Portal Session
- `POST /webhooks/stripe` — raw body, firma HMAC, idempotenza per `event.id`, dispatch su `customer.subscription.*`, `checkout.session.completed`, `invoice.payment_failed`
- Plugin `requirePro()` (402 payment_required) applicato a 9 rotte: meal-plans CRUD, weight-entries POST, check-ins POST, fast-events POST/PATCH, fasting/pause POST, export.pdf (plan+tracking)

**Shared** (`@ketopath/shared/billing/pro-status`):
- `isProActive(snap)` — verifica live (gestisce anche TRIALING con `trialEndsAt` passato in caso di cron in ritardo)
- `deriveProStatus(snap)` — kind + isPro + trialDaysRemaining + accessEndsAt per l'UI
- `computeTrialEndsAt(signupAt, days=30)`
- 11 unit test

**Frontend** (`apps/web/src/app/[locale]/billing/`):
- Pagina `/billing` editoriale (capitolo VIII) con StatusBlock per ogni kind, BillingActionsBar client (transitions, redirect a Stripe), 3 benefits
- `<TrialBanner>` in SignedInDashboard (3 stati: trial in corso oro / scaduto pomodoro / past_due pomodoro)
- Nav item "Abbonamento" (chapter VI) nel grid asimmetrico
- i18n IT completo (`Billing` namespace)

**Soft-degradation**: env Stripe (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID_*`, `BILLING_RETURN_URL`) sono tutte opzionali. Senza configurazione i route billing rispondono 503 e il banner trial mostra "pagamenti non ancora attivati" — utenti in trial continuano a usare l'app.

99/99 test verdi, lint pulito su tutto il monorepo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-05-07 11:27:06 +02:00
1 parent 41b3646325
commit f455cbe499
28 files changed
+2035 -486

No files matched your search

@@ -0,0 +1,66 @@
'use server';
import type { ProDerivedKind, SubscriptionStatus } from '@ketopath/shared';
import { headers } from 'next/headers';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
function cookieHeader(): string {
return headers().get('cookie') ?? '';
}
export interface BillingStatus {
subscription: {
status: SubscriptionStatus;
trialEndsAt: string;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
interval: 'MONTH' | 'YEAR' | null;
stripePriceId: string | null;
} | null;
derived: {
kind: ProDerivedKind;
isPro: boolean;
trialDaysRemaining: number | null;
accessEndsAt: string | null;
};
configured: boolean;
}
export async function fetchBillingStatus(): Promise<BillingStatus | null> {
const res = await fetch(`${API_URL}/me/billing/status`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return null;
return (await res.json()) as BillingStatus;
}
export async function startCheckout(
interval: 'MONTH' | 'YEAR',
): Promise<{ url: string } | { error: string }> {
const res = await fetch(`${API_URL}/me/billing/checkout`, {
method: 'POST',
headers: {
cookie: cookieHeader(),
'content-type': 'application/json',
},
body: JSON.stringify({ interval }),
cache: 'no-store',
});
if (res.status === 503) return { error: 'billing_not_configured' };
if (!res.ok) return { error: `api_error_${res.status}` };
return (await res.json()) as { url: string };
}
export async function openCustomerPortal(): Promise<{ url: string } | { error: string }> {
const res = await fetch(`${API_URL}/me/billing/portal`, {
method: 'POST',
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (res.status === 409) return { error: 'no_stripe_customer' };
if (res.status === 503) return { error: 'billing_not_configured' };
if (!res.ok) return { error: `api_error_${res.status}` };
return (await res.json()) as { url: string };
}
@@ -0,0 +1,59 @@
'use client';
import { useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { Button } from '@/components/ui/button';
import { openCustomerPortal, startCheckout } from './actions';
interface Props {
hasStripeCustomer: boolean;
isPro: boolean;
}
export function BillingActionsBar({ hasStripeCustomer, isPro }: Props) {
const t = useTranslations('Billing');
const [pending, startTransition] = useTransition();
function handleCheckout(interval: 'MONTH' | 'YEAR') {
startTransition(async () => {
const res = await startCheckout(interval);
if ('url' in res) {
window.location.href = res.url;
} else {
alert(t(`error.${res.error}`));
}
});
}
function handlePortal() {
startTransition(async () => {
const res = await openCustomerPortal();
if ('url' in res) {
window.location.href = res.url;
} else {
alert(t(`error.${res.error}`));
}
});
}
if (isPro && hasStripeCustomer) {
return (
<Button onClick={handlePortal} disabled={pending} size="lg">
{pending ? t('opening') : t('managePortal')}
</Button>
);
}
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<Button onClick={() => handleCheckout('MONTH')} disabled={pending} size="lg">
{pending ? t('opening') : t('upgradeMonthly')}
</Button>
<Button onClick={() => handleCheckout('YEAR')} disabled={pending} size="lg" variant="outline">
{pending ? t('opening') : t('upgradeYearly')}
</Button>
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { prisma } from '@ketopath/db';
import { redirect } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { CursorGlow } from '@/components/cursor-glow';
import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth';
import { fetchBillingStatus, type BillingStatus } from './actions';
import { BillingActionsBar } from './billing-actions-bar';
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
export default async function BillingPage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const session = await getServerSession();
if (!session?.user) redirect('/sign-in');
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { disclaimerAcceptedAt: true },
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
const status = await fetchBillingStatus();
return <BillingPageContent status={status} />;
}
function BillingPageContent({ status }: { status: BillingStatus | null }) {
const t = useTranslations('Billing');
if (!status) {
return (
<div className="relative">
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
<Masthead issueLabel="N. 08 — Abbonamento" />
<main className="pb-24 pt-12">
<p className="editorial-eyebrow">{t('errorLoading')}</p>
</main>
</div>
</div>
);
}
const { subscription, derived, configured } = status;
const hasStripeCustomer = subscription?.stripePriceId != null;
return (
<div className="relative">
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
<Masthead issueLabel="N. 08 — Abbonamento" />
<main className="relative overflow-hidden pb-24 pt-10 sm:pt-12">
<CursorGlow color="hsl(var(--oro))" size={520} />
<div
aria-hidden
className="mesh-blob mesh-blob--oro animate-float-y -right-32 top-12 h-[28rem] w-[28rem] opacity-50"
/>
<div
aria-hidden
className="mesh-blob mesh-blob--pomodoro animate-float-x -left-24 top-72 h-72 w-72 opacity-35"
/>
<span
aria-hidden
className="font-display text-stroke-thin text-chapter pointer-events-none absolute -right-4 -top-12 select-none italic"
>
VIII
</span>
<div
aria-hidden
className="pointer-events-none absolute right-0 top-32 hidden md:block"
style={{ writingMode: 'vertical-rl' }}
>
<p className="text-ink-soft font-mono text-xs uppercase tracking-[0.4em]">
Capitolo VIII — Abbonamento
</p>
</div>
<h1 className="font-display text-mega bleed-left animate-fade-up relative mt-8 font-medium [animation-delay:120ms]">
<span className="text-ink block">
{t('title')}
<span className="text-pomodoro">.</span>
</span>
</h1>
<p className="font-display text-ink-soft animate-fade-up relative mt-10 max-w-2xl text-2xl italic leading-snug [animation-delay:240ms] md:ml-[16.66%]">
{t('subtitle')}
</p>
<div className="rule animate-rule-in my-12 [animation-delay:360ms]" />
<section className="animate-fade-up relative grid gap-12 [animation-delay:420ms] md:grid-cols-12">
<div className="md:col-span-7">
<p className="editorial-eyebrow mb-4">{t('currentStatus')}</p>
<StatusBlock derived={derived} subscription={subscription} />
</div>
<aside className="md:border-ink/15 md:col-span-5 md:border-l md:pl-10">
<p className="editorial-eyebrow mb-4">{t('actions')}</p>
{!configured ? (
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('notConfigured')}
</p>
) : (
<div className="space-y-6">
<BillingActionsBar hasStripeCustomer={hasStripeCustomer} isPro={derived.isPro} />
{!derived.isPro || !hasStripeCustomer ? (
<p className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
{t('checkoutHint')}
</p>
) : null}
</div>
)}
</aside>
</section>
<div className="rule animate-rule-in my-16 [animation-delay:540ms]" />
<section className="animate-fade-up relative [animation-delay:600ms]">
<p className="editorial-eyebrow mb-4">{t('whatYouGet')}</p>
<div className="grid gap-8 md:grid-cols-3">
<Benefit num="01" titleKey="benefit1Title" bodyKey="benefit1Body" />
<Benefit num="02" titleKey="benefit2Title" bodyKey="benefit2Body" />
<Benefit num="03" titleKey="benefit3Title" bodyKey="benefit3Body" />
</div>
</section>
</main>
</div>
</div>
);
}
function StatusBlock({
derived,
subscription,
}: {
derived: BillingStatus['derived'];
subscription: BillingStatus['subscription'];
}) {
const t = useTranslations('Billing');
switch (derived.kind) {
case 'trial':
return (
<div>
<p className="font-display text-ink text-3xl leading-tight md:text-4xl">
{t('trialActive', { days: derived.trialDaysRemaining ?? 0 })}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('trialActiveBody', {
endsAt: derived.accessEndsAt
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
: '—',
})}
</p>
</div>
);
case 'trial_expired':
return (
<div>
<p className="font-display text-pomodoro text-3xl leading-tight md:text-4xl">
{t('trialExpired')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('trialExpiredBody')}
</p>
</div>
);
case 'active':
return (
<div>
<p className="font-display text-oliva text-3xl leading-tight md:text-4xl">
{t('proActive')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('proActiveBody', {
interval:
subscription?.interval === 'YEAR'
? t('intervalYear')
: subscription?.interval === 'MONTH'
? t('intervalMonth')
: '—',
renewsAt: derived.accessEndsAt
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
: '—',
})}
</p>
</div>
);
case 'past_due':
return (
<div>
<p className="font-display text-pomodoro text-3xl leading-tight md:text-4xl">
{t('pastDue')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('pastDueBody')}
</p>
</div>
);
case 'canceling':
return (
<div>
<p className="font-display text-ink text-3xl leading-tight md:text-4xl">
{t('canceling')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('cancelingBody', {
endsAt: derived.accessEndsAt
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
: '—',
})}
</p>
</div>
);
case 'canceled':
return (
<div>
<p className="font-display text-ink-dim text-3xl leading-tight md:text-4xl">
{t('canceled')}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('canceledBody')}
</p>
</div>
);
case 'no_subscription':
default:
return (
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('noSubscription')}
</p>
);
}
}
function Benefit({ num, titleKey, bodyKey }: { num: string; titleKey: string; bodyKey: string }) {
const t = useTranslations('Billing');
return (
<article>
<p className="font-display text-pomodoro text-2xl italic">{num}</p>
<h3 className="font-display text-ink mt-2 text-xl font-medium leading-tight">
{t(titleKey)}
</h3>
<p className="font-display text-ink-soft mt-3 text-sm italic leading-snug">{t(bodyKey)}</p>
</article>
);
}
+82 -4
View File
@@ -6,21 +6,34 @@ import { CursorGlow } from '@/components/cursor-glow';
import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth';
import { fetchBillingStatus, type BillingStatus } from './billing/actions';
import { SignOutButton } from './sign-out-button';
export default async function HomePage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const session = await getServerSession();
const userName = session?.user?.name ?? session?.user?.email ?? null;
const billing = userName ? await fetchBillingStatus() : null;
return <HomeContent userName={session?.user?.name ?? session?.user?.email ?? null} />;
return <HomeContent userName={userName} billing={billing} />;
}
function HomeContent({ userName }: { userName: string | null }) {
function HomeContent({
userName,
billing,
}: {
userName: string | null;
billing: BillingStatus | null;
}) {
return (
<div className="relative">
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
<Masthead />
{userName ? <SignedInDashboard userName={userName} /> : <MarketingLanding />}
{userName ? (
<SignedInDashboard userName={userName} billing={billing} />
) : (
<MarketingLanding />
)}
</div>
</div>
);
@@ -800,7 +813,13 @@ function Disclaimer() {
);
}
function SignedInDashboard({ userName }: { userName: string }) {
function SignedInDashboard({
userName,
billing,
}: {
userName: string;
billing: BillingStatus | null;
}) {
const t = useTranslations('Home');
return (
<main className="relative min-h-[80vh] overflow-hidden pb-24">
@@ -848,6 +867,8 @@ function SignedInDashboard({ userName }: { userName: string }) {
{t('tagline')}
</p>
<TrialBanner billing={billing} />
{/* Nav asimmetrica: card grande "/plan" a sinistra, le altre 4 in colonna a destra */}
<div className="animate-fade-up relative mt-20 grid grid-cols-12 gap-6 [animation-delay:360ms]">
<DashboardHero
@@ -861,6 +882,7 @@ function SignedInDashboard({ userName }: { userName: string }) {
<NavItem href="/fasting" label={t('viewFasting')} eyebrow="Digiuno" chapter="III" />
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" chapter="IV" />
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" chapter="V" />
<NavItem href="/billing" label={t('viewBilling')} eyebrow="Abbonamento" chapter="VI" />
<li className="pt-6">
<SignOutButton label={t('signOut')} />
</li>
@@ -957,3 +979,59 @@ function NavItem({
</li>
);
}
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
month: 'long',
});
function TrialBanner({ billing }: { billing: BillingStatus | null }) {
const t = useTranslations('Billing');
if (!billing) return null;
const { derived } = billing;
if (derived.kind === 'trial' && derived.trialDaysRemaining != null) {
const endsAt = derived.accessEndsAt ? ITALIAN_DATE.format(new Date(derived.accessEndsAt)) : '—';
return (
<aside className="border-oro/35 bg-oro/5 animate-fade-up relative mt-12 border-2 border-dashed p-6 [animation-delay:300ms] md:ml-[16.66%]">
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:justify-between">
<div>
<p className="editorial-eyebrow">{t('trialBannerTitle')}</p>
<p className="font-display text-ink mt-2 text-lg leading-snug">
{t('trialBannerBody', { days: derived.trialDaysRemaining, endsAt })}
</p>
</div>
<Link
href="/billing"
className="text-oro hover:text-pomodoro shrink-0 font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]"
>
{t('trialBannerCta')} →
</Link>
</div>
</aside>
);
}
if (derived.kind === 'trial_expired' || derived.kind === 'past_due') {
return (
<aside className="border-pomodoro/40 bg-pomodoro/5 animate-fade-up relative mt-12 border-2 border-dashed p-6 [animation-delay:300ms] md:ml-[16.66%]">
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:justify-between">
<div>
<p className="editorial-eyebrow">{t('trialEndedBannerTitle')}</p>
<p className="font-display text-ink mt-2 text-lg leading-snug">
{t('trialEndedBannerBody')}
</p>
</div>
<Link
href="/billing"
className="text-pomodoro hover:text-ink shrink-0 font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]"
>
{t('trialEndedBannerCta')} →
</Link>
</div>
</aside>
);
}
return null;
}