From f455cbe49940b55be496b7b724194e632f5b939a Mon Sep 17 00:00:00 2001 From: luciano Date: Thu, 7 May 2026 11:27:06 +0200 Subject: [PATCH] feat(billing): Stripe + abbonamento Pro con trial 30gg (ADR 0004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - `` 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) --- CLAUDE.md | 2 +- apps/api/.env.example | 13 + apps/api/package.json | 1 + apps/api/src/app.ts | 4 + .../api/src/modules/billing/billing.routes.ts | 133 ++++++ apps/api/src/modules/billing/env.ts | 34 ++ apps/api/src/modules/billing/service.ts | 168 ++++++++ apps/api/src/modules/billing/stripe-client.ts | 26 ++ .../api/src/modules/billing/webhook.routes.ts | 127 ++++++ apps/api/src/modules/plan/export.routes.ts | 3 +- apps/api/src/modules/plan/plan.routes.ts | 398 +++++++++--------- .../src/modules/tracking/check-in.routes.ts | 65 +-- .../api/src/modules/tracking/export.routes.ts | 283 +++++++------ apps/api/src/modules/tracking/fast.routes.ts | 171 ++++---- .../api/src/modules/tracking/weight.routes.ts | 87 ++-- apps/api/src/plugins/require-pro.ts | 29 ++ apps/web/messages/it.json | 53 +++ apps/web/src/app/[locale]/billing/actions.ts | 66 +++ .../[locale]/billing/billing-actions-bar.tsx | 59 +++ apps/web/src/app/[locale]/billing/page.tsx | 250 +++++++++++ apps/web/src/app/[locale]/page.tsx | 86 +++- docs/decisions/0004-payment-provider.md | 107 +++++ .../migration.sql | 53 +++ packages/db/prisma/schema.prisma | 61 +++ .../shared/src/billing/pro-status.test.ts | 110 +++++ packages/shared/src/billing/pro-status.ts | 131 ++++++ packages/shared/src/index.ts | 1 + pnpm-lock.yaml | Bin 339616 -> 340210 bytes 28 files changed, 2035 insertions(+), 486 deletions(-) create mode 100644 apps/api/src/modules/billing/billing.routes.ts create mode 100644 apps/api/src/modules/billing/env.ts create mode 100644 apps/api/src/modules/billing/service.ts create mode 100644 apps/api/src/modules/billing/stripe-client.ts create mode 100644 apps/api/src/modules/billing/webhook.routes.ts create mode 100644 apps/api/src/plugins/require-pro.ts create mode 100644 apps/web/src/app/[locale]/billing/actions.ts create mode 100644 apps/web/src/app/[locale]/billing/billing-actions-bar.tsx create mode 100644 apps/web/src/app/[locale]/billing/page.tsx create mode 100644 docs/decisions/0004-payment-provider.md create mode 100644 packages/db/prisma/migrations/20260507091227_add_subscriptions/migration.sql create mode 100644 packages/shared/src/billing/pro-status.test.ts create mode 100644 packages/shared/src/billing/pro-status.ts diff --git a/CLAUDE.md b/CLAUDE.md index f766f22..b7c6ead 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,7 +185,7 @@ Quando ti chiedo una nuova funzionalità, segui questo flusso: Queste decisioni non sono ancora finalizzate. Se le tocchi, aprire un ADR in `docs/decisions/`. -- [ ] Stripe vs Lemon Squeezy per il payment provider +- [x] ~~Stripe vs Lemon Squeezy per il payment provider~~ → Stripe + free 30gg → Pro (vedi `docs/decisions/0004-payment-provider.md`) - [ ] PostHog self-hosted vs Mixpanel per analytics di prodotto - [ ] Render vs Fly.io vs AWS ECS per l'hosting backend - [ ] Strategia di seeding del database ricette (manuale vs scraping autorizzato vs LLM-assisted) diff --git a/apps/api/.env.example b/apps/api/.env.example index 1dd1546..be17bb4 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -31,3 +31,16 @@ BETTER_AUTH_URL=http://localhost:3000 VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:hello@ketopath.app + +# Stripe — abbonamenti (vedi ADR 0004). Tutti opzionali: senza queste env il +# webhook e gli endpoint /me/billing/* rispondono 503; le route gated (genera +# piano, pesata, fast, export PDF) continuano a funzionare per gli utenti in +# trial o non gated, ma chi è EXPIRED riceve 402 a prescindere. +# Usa le keys "test mode" per sviluppo (sk_test_..., whsec_...). +# I price ID si creano da dashboard Stripe → Products. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_ID_MONTHLY= +STRIPE_PRICE_ID_YEARLY= +# URL di base usato nelle redirect post-checkout (success/cancel). +BILLING_RETURN_URL=http://localhost:3000 diff --git a/apps/api/package.json b/apps/api/package.json index 2598d49..0e51b6a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "fastify-plugin": "^4.5.1", "node-cron": "^3.0", "pdfkit": "^0.15", + "stripe": "^22.1.1", "web-push": "^3.6", "zod": "^3.23.8" }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3c84375..f007e20 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,8 @@ import Fastify, { type FastifyInstance } from 'fastify'; import { env } from './config/env.js'; import { Sentry } from './lib/sentry.js'; import { achievementsRoutes } from './modules/achievements/achievements.routes.js'; +import { billingRoutes } from './modules/billing/billing.routes.js'; +import { stripeWebhookRoutes } from './modules/billing/webhook.routes.js'; import { dbRoutes } from './modules/db/db.routes.js'; import { healthRoutes } from './modules/health/health.routes.js'; import { gdprRoutes } from './modules/me/gdpr.routes.js'; @@ -68,6 +70,8 @@ export async function buildApp(): Promise { await app.register(shoppingRoutes); await app.register(notificationsRoutes); await app.register(achievementsRoutes); + await app.register(billingRoutes); + await app.register(stripeWebhookRoutes); if (env.SENTRY_DSN) { app.setErrorHandler((err, request, reply) => { diff --git a/apps/api/src/modules/billing/billing.routes.ts b/apps/api/src/modules/billing/billing.routes.ts new file mode 100644 index 0000000..6922605 --- /dev/null +++ b/apps/api/src/modules/billing/billing.routes.ts @@ -0,0 +1,133 @@ +import { deriveProStatus } from '@ketopath/shared'; +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; + +import { requireAuth } from '../../plugins/auth.js'; + +import { billingEnv, isBillingConfigured } from './env.js'; +import { ensureSubscription, getSubscription, toSnapshot } from './service.js'; +import { getStripe } from './stripe-client.js'; + +const checkoutBodySchema = z.object({ + interval: z.enum(['MONTH', 'YEAR']), +}); + +export const billingRoutes: FastifyPluginAsync = async (fastify) => { + /** + * GET /me/billing/status — stato corrente dell'abbonamento + derived. + * Risponde sempre 200 (anche per utenti senza subscription record): il + * frontend usa lo stesso payload sia per chi non ha mai aperto la pagina + * billing sia per chi è in trial / pro / canceled. + */ + fastify.get('/me/billing/status', { preHandler: requireAuth() }, async (request) => { + const userId = request.user!.id; + const signupAt = request.user!.createdAt; + // Side effect benefico: chiamare /status la prima volta crea il record + // di trial se non esiste. Idempotente. + const sub = await ensureSubscription(fastify.prisma, userId, signupAt); + const snap = toSnapshot(sub); + const derived = deriveProStatus(snap); + return { + subscription: snap + ? { + status: snap.status, + trialEndsAt: snap.trialEndsAt.toISOString(), + currentPeriodEnd: snap.currentPeriodEnd?.toISOString() ?? null, + cancelAtPeriodEnd: snap.cancelAtPeriodEnd, + interval: sub.interval, + stripePriceId: sub.stripePriceId, + } + : null, + derived: { + kind: derived.kind, + isPro: derived.isPro, + trialDaysRemaining: derived.trialDaysRemaining, + accessEndsAt: derived.accessEndsAt?.toISOString() ?? null, + }, + configured: isBillingConfigured(), + }; + }); + + /** + * POST /me/billing/checkout — crea una Stripe Checkout Session per il + * primo upgrade. Restituisce l'URL hosted Stripe a cui redirigere l'utente. + */ + fastify.post('/me/billing/checkout', { preHandler: requireAuth() }, async (request, reply) => { + if (!isBillingConfigured()) { + return reply.code(503).send({ error: 'billing_not_configured' }); + } + const body = checkoutBodySchema.safeParse(request.body); + if (!body.success) { + return reply.code(400).send({ error: 'invalid_body', issues: body.error.issues }); + } + const { interval } = body.data; + const priceId = + interval === 'YEAR' ? billingEnv.STRIPE_PRICE_ID_YEARLY : billingEnv.STRIPE_PRICE_ID_MONTHLY; + if (!priceId) { + return reply.code(503).send({ error: 'price_not_configured' }); + } + + const userId = request.user!.id; + const userEmail = request.user!.email; + const signupAt = request.user!.createdAt; + const stripe = getStripe(); + + const sub = await ensureSubscription(fastify.prisma, userId, signupAt); + + // Riusa il customer Stripe se l'utente ha già pagato in passato; altrimenti + // ne creiamo uno nuovo passando metadata.userId per il webhook. + let customerId = sub.stripeCustomerId; + if (!customerId) { + const customer = await stripe.customers.create({ + email: userEmail, + metadata: { userId }, + }); + customerId = customer.id; + await fastify.prisma.subscription.update({ + where: { userId }, + data: { stripeCustomerId: customerId }, + }); + } + + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${billingEnv.BILLING_RETURN_URL}/billing?status=success&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${billingEnv.BILLING_RETURN_URL}/billing?status=canceled`, + // Critico: il webhook risale all'utente da queste metadata. + subscription_data: { metadata: { userId } }, + // Stripe Tax: calcolo automatico dell'IVA UE (vedi ADR 0004). + automatic_tax: { enabled: true }, + customer_update: { address: 'auto', name: 'auto' }, + // Permette all'utente di inserire una P.IVA (per chi compra B2B). + tax_id_collection: { enabled: true }, + allow_promotion_codes: true, + locale: 'it', + }); + + return reply.send({ url: session.url }); + }); + + /** + * POST /me/billing/portal — crea una Customer Portal Session per la + * gestione self-service di abbonamento, fatture, metodo di pagamento. + */ + fastify.post('/me/billing/portal', { preHandler: requireAuth() }, async (request, reply) => { + if (!isBillingConfigured()) { + return reply.code(503).send({ error: 'billing_not_configured' }); + } + const userId = request.user!.id; + const sub = await getSubscription(fastify.prisma, userId); + if (!sub?.stripeCustomerId) { + return reply.code(409).send({ error: 'no_stripe_customer' }); + } + const stripe = getStripe(); + const session = await stripe.billingPortal.sessions.create({ + customer: sub.stripeCustomerId, + return_url: `${billingEnv.BILLING_RETURN_URL}/billing`, + locale: 'it', + }); + return reply.send({ url: session.url }); + }); +}; diff --git a/apps/api/src/modules/billing/env.ts b/apps/api/src/modules/billing/env.ts new file mode 100644 index 0000000..b458081 --- /dev/null +++ b/apps/api/src/modules/billing/env.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * ADR 0004 — env vars Stripe. Tutte opzionali: se mancano, i flussi di + * billing rispondono 503 (come abbiamo fatto per VAPID in ADR 0003). + * Questo permette al PO di sviluppare features non-billing senza dover + * configurare Stripe localmente. + */ +const billingEnvSchema = z.object({ + STRIPE_SECRET_KEY: z.string().min(1).optional(), + STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), + STRIPE_PRICE_ID_MONTHLY: z.string().min(1).optional(), + STRIPE_PRICE_ID_YEARLY: z.string().min(1).optional(), + // URL di base usato per le redirect post-checkout. Deve coincidere col + // dominio del frontend (apps/web). + BILLING_RETURN_URL: z.string().url().optional(), +}); + +export type BillingEnv = z.infer; + +export function readBillingEnv(source: NodeJS.ProcessEnv = process.env): BillingEnv { + return billingEnvSchema.parse(source); +} + +export const billingEnv: BillingEnv = readBillingEnv(); + +export function isBillingConfigured(env: BillingEnv = billingEnv): boolean { + return Boolean( + env.STRIPE_SECRET_KEY && + env.STRIPE_WEBHOOK_SECRET && + env.STRIPE_PRICE_ID_MONTHLY && + env.BILLING_RETURN_URL, + ); +} diff --git a/apps/api/src/modules/billing/service.ts b/apps/api/src/modules/billing/service.ts new file mode 100644 index 0000000..32984aa --- /dev/null +++ b/apps/api/src/modules/billing/service.ts @@ -0,0 +1,168 @@ +import { + computeTrialEndsAt, + isProActive, + type SubscriptionSnapshot, + type SubscriptionStatus as SharedStatus, +} from '@ketopath/shared'; +import type { PrismaClient, Subscription } from '@prisma/client'; +// eslint-disable-next-line import/no-named-as-default +import type Stripe from 'stripe'; + +/** + * ADR 0004 — service di billing. Orchestrazione tra Stripe e DB locale. + * Le route lo invocano per leggere/scrivere lo stato subscription; il + * webhook lo invoca per applicare gli eventi `customer.subscription.*`. + */ + +export function toSnapshot(sub: Subscription | null): SubscriptionSnapshot | null { + if (!sub) return null; + return { + status: sub.status as SharedStatus, + trialEndsAt: sub.trialEndsAt, + currentPeriodEnd: sub.currentPeriodEnd, + cancelAtPeriodEnd: sub.cancelAtPeriodEnd, + }; +} + +/** + * Crea il record Subscription per l'utente se non esiste — il trial parte + * dalla data di registrazione. Idempotente: se esiste già, non tocca nulla. + */ +export async function ensureSubscription( + prisma: PrismaClient, + userId: string, + signupAt: Date, +): Promise { + return prisma.subscription.upsert({ + where: { userId }, + create: { + userId, + status: 'TRIALING', + trialEndsAt: computeTrialEndsAt(signupAt), + }, + update: {}, + }); +} + +export async function getSubscription( + prisma: PrismaClient, + userId: string, +): Promise { + return prisma.subscription.findUnique({ where: { userId } }); +} + +export async function isUserPro(prisma: PrismaClient, userId: string): Promise { + const sub = await getSubscription(prisma, userId); + return isProActive(toSnapshot(sub)); +} + +/* ───────────────────────────────────────────────────────────────────── + * Mapping da Stripe.Subscription al nostro stato. + * ────────────────────────────────────────────────────────────────────── */ + +function mapStatus( + stripeStatus: Stripe.Subscription.Status, + cancelAtPeriodEnd: boolean, +): SharedStatus { + if (cancelAtPeriodEnd && (stripeStatus === 'active' || stripeStatus === 'trialing')) { + return 'CANCEL_AT_PERIOD_END'; + } + switch (stripeStatus) { + case 'trialing': + return 'TRIALING'; + case 'active': + return 'ACTIVE'; + case 'past_due': + return 'PAST_DUE'; + case 'canceled': + case 'incomplete_expired': + case 'unpaid': + return 'CANCELED'; + case 'incomplete': + case 'paused': + // Stati transitori — manteniamo lo stato corrente in DB. Stripe rinotifica + // appena la situazione si stabilizza. Per sicurezza torniamo PAST_DUE. + return 'PAST_DUE'; + } +} + +function mapInterval( + interval: Stripe.Price.Recurring.Interval | null | undefined, +): 'MONTH' | 'YEAR' | null { + if (interval === 'month') return 'MONTH'; + if (interval === 'year') return 'YEAR'; + return null; +} + +/** + * Applica un evento `customer.subscription.*` o `checkout.session.completed` + * di Stripe al record locale. Idempotente: lo possiamo invocare con lo stesso + * payload N volte senza side effect. + */ +export async function applyStripeSubscription( + prisma: PrismaClient, + stripeSub: Stripe.Subscription, +): Promise { + const userId = stripeSub.metadata?.userId; + if (!userId) { + throw new Error(`stripe subscription ${stripeSub.id} priva di metadata.userId`); + } + const item = stripeSub.items.data[0]; + if (!item) throw new Error(`stripe subscription ${stripeSub.id} senza items`); + + const cancelAtPeriodEnd = stripeSub.cancel_at_period_end; + const status = mapStatus(stripeSub.status, cancelAtPeriodEnd); + const currentPeriodEnd = item.current_period_end + ? new Date(item.current_period_end * 1000) + : null; + const trialEnd = stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null; + const customerId = + typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id; + + await prisma.subscription.upsert({ + where: { userId }, + create: { + userId, + status, + // Se Stripe ci dà un trial_end (caso del trial-with-card), lo prendiamo. + // Altrimenti usiamo il trial nativo a 30gg dalla creazione del record. + trialEndsAt: trialEnd ?? computeTrialEndsAt(new Date()), + stripeCustomerId: customerId, + stripeSubscriptionId: stripeSub.id, + stripePriceId: item.price.id, + currentPeriodEnd, + interval: mapInterval(item.price.recurring?.interval), + cancelAtPeriodEnd, + endedAt: status === 'CANCELED' ? new Date() : null, + }, + update: { + status, + stripeCustomerId: customerId, + stripeSubscriptionId: stripeSub.id, + stripePriceId: item.price.id, + currentPeriodEnd, + interval: mapInterval(item.price.recurring?.interval), + cancelAtPeriodEnd, + endedAt: status === 'CANCELED' ? new Date() : null, + }, + }); +} + +/** + * Marca come EXPIRED le subscription TRIALING il cui trial è scaduto. + * Invocata da uno scheduler giornaliero (vedi notifications/scheduler). + * Restituisce il numero di record aggiornati. + */ +export async function expireFinishedTrials(prisma: PrismaClient, now: Date = new Date()) { + const res = await prisma.subscription.updateMany({ + where: { + status: 'TRIALING', + trialEndsAt: { lte: now }, + }, + data: { + status: 'EXPIRED', + endedAt: now, + }, + }); + return res.count; +} diff --git a/apps/api/src/modules/billing/stripe-client.ts b/apps/api/src/modules/billing/stripe-client.ts new file mode 100644 index 0000000..6c147ee --- /dev/null +++ b/apps/api/src/modules/billing/stripe-client.ts @@ -0,0 +1,26 @@ +// eslint-disable-next-line import/no-named-as-default +import Stripe from 'stripe'; + +import { billingEnv } from './env.js'; + +/** + * Singleton Stripe client. Costruito lazy: se `STRIPE_SECRET_KEY` non è + * configurato (es. in CI / sviluppo locale senza account Stripe), `getStripe` + * lancia. Le route che dipendono da Stripe rispondono 503 prima di chiamare. + */ +let _stripe: Stripe | null = null; + +export function getStripe(): Stripe { + if (_stripe) return _stripe; + if (!billingEnv.STRIPE_SECRET_KEY) { + throw new Error('STRIPE_SECRET_KEY non configurata'); + } + _stripe = new Stripe(billingEnv.STRIPE_SECRET_KEY, { + // Pin esplicito: niente sorprese quando Stripe rilascia una nuova + // apiVersion. Aggiornare insieme allo `stripe` package. + apiVersion: '2025-09-30.clover', + typescript: true, + appInfo: { name: 'KetoPath', version: '0.0.0' }, + }); + return _stripe; +} diff --git a/apps/api/src/modules/billing/webhook.routes.ts b/apps/api/src/modules/billing/webhook.routes.ts new file mode 100644 index 0000000..e5d4c39 --- /dev/null +++ b/apps/api/src/modules/billing/webhook.routes.ts @@ -0,0 +1,127 @@ +import type { PrismaClient } from '@prisma/client'; +import type { FastifyPluginAsync } from 'fastify'; +// eslint-disable-next-line import/no-named-as-default +import type Stripe from 'stripe'; + +import { billingEnv, isBillingConfigured } from './env.js'; +import { applyStripeSubscription } from './service.js'; +import { getStripe } from './stripe-client.js'; + +/** + * ADR 0004 — webhook Stripe. Receive raw body, verifica firma HMAC, + * idempotenza via tabella `BillingWebhookEvent`, dispatch agli handler + * per i 5 eventi che ci interessano. + * + * Encapsulation: il content-type parser raw è registrato dentro un + * `fastify.register()` scope così non interferisce con le altre rotte + * (che si aspettano JSON parsato). + */ +export const stripeWebhookRoutes: FastifyPluginAsync = async (parent) => { + await parent.register(async (instance) => { + instance.addContentTypeParser( + 'application/json', + { parseAs: 'buffer' }, + (_request, body, done) => { + done(null, body); + }, + ); + + instance.post('/webhooks/stripe', async (request, reply) => { + if (!isBillingConfigured()) { + return reply.code(503).send({ error: 'billing_not_configured' }); + } + const signature = request.headers['stripe-signature']; + if (typeof signature !== 'string') { + return reply.code(400).send({ error: 'missing_signature' }); + } + const rawBody = request.body as Buffer; + const stripe = getStripe(); + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent( + rawBody, + signature, + billingEnv.STRIPE_WEBHOOK_SECRET!, + ); + } catch (err) { + request.log.warn({ err }, 'webhook signature verification failed'); + return reply.code(400).send({ error: 'invalid_signature' }); + } + + // Idempotenza: se l'evento è già stato visto, rispondiamo 200 senza + // ri-eseguire l'handler. Stripe accetta anche 200 su evento già processato. + const existing = await instance.prisma.billingWebhookEvent.findUnique({ + where: { id: event.id }, + }); + if (existing?.processedAt) { + request.log.debug({ eventId: event.id }, 'webhook already processed, skipping'); + return reply.code(200).send({ received: true, duplicate: true }); + } + // Registriamo l'evento (anche se l'handler fallirà sotto, così abbiamo + // un audit trail di tutto quello che Stripe ci ha mandato). + await instance.prisma.billingWebhookEvent.upsert({ + where: { id: event.id }, + create: { + id: event.id, + type: event.type, + payload: event as unknown as Stripe.Event, + }, + update: {}, + }); + + try { + await dispatchEvent(instance.prisma, stripe, event); + await instance.prisma.billingWebhookEvent.update({ + where: { id: event.id }, + data: { processedAt: new Date() }, + }); + return reply.code(200).send({ received: true }); + } catch (err) { + request.log.error({ err, eventId: event.id, type: event.type }, 'webhook handler failed'); + // 500 → Stripe ritenta. processedAt resta null, idempotenza ok al + // prossimo retry. + return reply.code(500).send({ error: 'handler_failed' }); + } + }); + }); +}; + +async function dispatchEvent( + prisma: PrismaClient, + stripe: Stripe, + event: Stripe.Event, +): Promise { + switch (event.type) { + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const sub = event.data.object as Stripe.Subscription; + await applyStripeSubscription(prisma, sub); + return; + } + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + if (session.mode !== 'subscription' || !session.subscription) return; + const subId = + typeof session.subscription === 'string' ? session.subscription : session.subscription.id; + // Recuperiamo la subscription completa (con items expansi) per avere + // tutti i dati che ci servono per applyStripeSubscription. + const fullSub = await stripe.subscriptions.retrieve(subId); + await applyStripeSubscription(prisma, fullSub); + return; + } + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + const lineSub = invoice.lines.data.find((line) => line.subscription)?.subscription; + if (!lineSub) return; + const subId = typeof lineSub === 'string' ? lineSub : lineSub.id; + const fullSub = await stripe.subscriptions.retrieve(subId); + await applyStripeSubscription(prisma, fullSub); + return; + } + default: + // Eventi non gestiti — log silenzioso. Stripe ne manda decine, normale. + return; + } +} diff --git a/apps/api/src/modules/plan/export.routes.ts b/apps/api/src/modules/plan/export.routes.ts index 3d14057..380b4e1 100644 --- a/apps/api/src/modules/plan/export.routes.ts +++ b/apps/api/src/modules/plan/export.routes.ts @@ -7,6 +7,7 @@ import type { FastifyPluginAsync } from 'fastify'; import PDFDocument from 'pdfkit'; import { requireAuth } from '../../plugins/auth.js'; +import { requirePro } from '../../plugins/require-pro.js'; const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { day: 'numeric', @@ -26,7 +27,7 @@ const MEAL_LABELS: Record = { export const planExportRoutes: FastifyPluginAsync = async (fastify) => { fastify.get( '/me/meal-plans/export.pdf', - { preHandler: requireAuth() }, + { preHandler: [requireAuth(), requirePro()] }, async (request, reply) => { const userId = request.user!.id; diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index b753139..a7a4baf 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -24,6 +24,7 @@ import { import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +import { requirePro } from '../../plugins/require-pro.js'; import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js'; const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const; @@ -72,220 +73,225 @@ function parseConditions(raw: string | null): string[] { } export const planRoutes: FastifyPluginAsync = async (fastify) => { - fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => { - const userId = request.user!.id; + fastify.post( + '/me/meal-plans', + { preHandler: [requireAuth(), requirePro()] }, + async (request, reply) => { + const userId = request.user!.id; - const profile = await fastify.prisma.profile.findUnique({ - where: { userId }, - include: { user: { include: { preferences: true } } }, - }); - if (!profile) return reply.code(409).send({ error: 'profile_required' }); + const profile = await fastify.prisma.profile.findUnique({ + where: { userId }, + include: { user: { include: { preferences: true } } }, + }); + if (!profile) return reply.code(409).send({ error: 'profile_required' }); - const recipes = await fastify.prisma.recipe.findMany({ - select: { - id: true, - name: true, - category: true, - kcal: true, - proteinG: true, - fatG: true, - netCarbG: true, - phases: true, - prepMinutes: true, - ingredients: { - select: { - ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } }, + const recipes = await fastify.prisma.recipe.findMany({ + select: { + id: true, + name: true, + category: true, + kcal: true, + proteinG: true, + fatG: true, + netCarbG: true, + phases: true, + prepMinutes: true, + ingredients: { + select: { + ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } }, + }, }, }, - }, - }); - if (recipes.length === 0) { - return reply.code(409).send({ error: 'recipe_catalog_empty' }); - } - - // PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette - // che contengono ingredienti non ancora reintrodotti per la settimana - // corrente di fase 2. - const phaseIntForFilter = phaseToInt(profile.currentPhase); - const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt); - const allowedRecipes = recipes.filter((r) => - isRecipeAllowedForPhaseWeek( - r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })), - phaseIntForFilter, - phase2WeekForFilter, - ), - ); - - const candidates: RecipeCandidate[] = allowedRecipes.map((r) => { - const tags = new Set(); - const ingredientIds: string[] = []; - for (const ri of r.ingredients) { - for (const g of ri.ingredient.exclusionGroups) tags.add(g); - ingredientIds.push(ri.ingredient.id); + }); + if (recipes.length === 0) { + return reply.code(409).send({ error: 'recipe_catalog_empty' }); } - return { - id: r.id, - name: r.name, - category: r.category, - kcal: r.kcal, - proteinG: r.proteinG, - fatG: r.fatG, - netCarbG: r.netCarbG, - exclusionTags: Array.from(tags), - ingredientIds, - phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), - prepMinutes: r.prepMinutes, - }; - }); - // Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e - // rimandiamo l'utente al medico (lato UI mostriamo un disclaimer). - const conditions = parseConditions(profile.medicalConditions); - if (hasExcludingCondition(conditions)) { - return reply.code(409).send({ error: 'medical_block', conditions }); - } + // PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette + // che contengono ingredienti non ancora reintrodotti per la settimana + // corrente di fase 2. + const phaseIntForFilter = phaseToInt(profile.currentPhase); + const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt); + const allowedRecipes = recipes.filter((r) => + isRecipeAllowedForPhaseWeek( + r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })), + phaseIntForFilter, + phase2WeekForFilter, + ), + ); - const weightCurrentKg = Number(profile.weightCurrentKg); - const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null; - // BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin. - let bmr = bodyFatPct - ? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct) - : calculateBmr({ - weightKg: weightCurrentKg, - heightCm: profile.heightCm, - ageYears: profile.age, - gender: profile.gender, - }); - // Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%). - bmr *= bmrAdjustmentForConditions(conditions); - bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null); - const tdee = calculateTdee(bmr, profile.activityLevel); - const phaseInt = phaseToInt(profile.currentPhase); + const candidates: RecipeCandidate[] = allowedRecipes.map((r) => { + const tags = new Set(); + const ingredientIds: string[] = []; + for (const ri of r.ingredients) { + for (const g of ri.ingredient.exclusionGroups) tags.add(g); + ingredientIds.push(ri.ingredient.id); + } + return { + id: r.id, + name: r.name, + category: r.category, + kcal: r.kcal, + proteinG: r.proteinG, + fatG: r.fatG, + netCarbG: r.netCarbG, + exclusionTags: Array.from(tags), + ingredientIds, + phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), + prepMinutes: r.prepMinutes, + }; + }); - // Schedule allenamento: kcal extra sui giorni di allenamento. - const trainingDays = profile.user.preferences?.trainingDays ?? []; - const trainingType = - (profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null; - const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null; - const sessionExtraKcal = - trainingType && sessionMinutes - ? extraKcalForSession({ - type: trainingType, - durationMinutes: sessionMinutes, + // Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e + // rimandiamo l'utente al medico (lato UI mostriamo un disclaimer). + const conditions = parseConditions(profile.medicalConditions); + if (hasExcludingCondition(conditions)) { + return reply.code(409).send({ error: 'medical_block', conditions }); + } + + const weightCurrentKg = Number(profile.weightCurrentKg); + const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null; + // BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin. + let bmr = bodyFatPct + ? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct) + : calculateBmr({ weightKg: weightCurrentKg, - }) - : 0; - const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null; + heightCm: profile.heightCm, + ageYears: profile.age, + gender: profile.gender, + }); + // Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%). + bmr *= bmrAdjustmentForConditions(conditions); + bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null); + const tdee = calculateTdee(bmr, profile.activityLevel); + const phaseInt = phaseToInt(profile.currentPhase); - // Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza). - const { kcalTarget: baseKcal } = computeDailyKcalTarget({ - tdee, - weightCurrentKg, - targetWeeklyLossKg: profile.targetWeeklyLossKg, - phase: phaseInt, - }); - const baseDailyTarget = macrosForPhase({ - kcalTarget: baseKcal, - weightKg: weightCurrentKg, - phase: phaseInt, - }); - const exclusions = profile.user.preferences?.exclusions ?? []; - const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? []; - const fastingProtocol = - (profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null; - const cookingTime = - (profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ?? - null; - const maxPrepMinutes = maxPrepMinutesFor(cookingTime); + // Schedule allenamento: kcal extra sui giorni di allenamento. + const trainingDays = profile.user.preferences?.trainingDays ?? []; + const trainingType = + (profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null; + const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null; + const sessionExtraKcal = + trainingType && sessionMinutes + ? extraKcalForSession({ + type: trainingType, + durationMinutes: sessionMinutes, + weightKg: weightCurrentKg, + }) + : 0; + const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null; - const weekStart = startOfWeek(new Date()); - - const plan = await fastify.prisma.mealPlan.upsert({ - where: { userId_weekStart: { userId, weekStart } }, - create: { userId, weekStart }, - update: { generatedAt: new Date(), status: 'ACTIVE' }, - }); - - // Wipe existing slots before regenerating, in case the plan already existed. - await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } }); - - const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = []; - for (let day = 0; day < 7; day++) { - const dayPlan = protocolPlanForDay(fastingProtocol, day); - // Giorno di digiuno completo (ESE_24): salta tutto. - if (dayPlan.kcalMultiplier === 0) continue; - - // Se non c'è un protocollo IF attivo e l'utente ha dichiarato - // mealsPerDay, lo usiamo per ridistribuire le quote per pasto. - const effectiveShare = - !fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share; - - // Sui giorni di allenamento aumentiamo le kcal per coprire l'EE - // della sessione (Ainsworth 2011 — vedi preferences/training.ts). - const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0; - const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra); - const dailyTarget = macrosForPhase({ - kcalTarget: dailyKcal, + // Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza). + const { kcalTarget: baseKcal } = computeDailyKcalTarget({ + tdee, + weightCurrentKg, + targetWeeklyLossKg: profile.targetWeeklyLossKg, + phase: phaseInt, + }); + const baseDailyTarget = macrosForPhase({ + kcalTarget: baseKcal, weightKg: weightCurrentKg, phase: phaseInt, }); + const exclusions = profile.user.preferences?.exclusions ?? []; + const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? []; + const fastingProtocol = + (profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? + null; + const cookingTime = + (profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ?? + null; + const maxPrepMinutes = maxPrepMinutesFor(cookingTime); - // Ricette scelte negli ultimi 2 giorni sono "recenti". - const recentlyConsumedIds: string[] = plannedSlots - .filter((s) => day - s.day <= 2 && s.recipeId) - .map((s) => s.recipeId!); + const weekStart = startOfWeek(new Date()); - for (const meal of MEALS) { - // Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot. - if (effectiveShare[meal] === 0) continue; - - const top = matchMeals({ - candidates, - meal, - phase: phaseInt, - excludedTags: exclusions, - bannedIngredientIds, - recentlyConsumedIds, - dailyTarget, - mealShare: effectiveShare, - maxPrepMinutes, - topN: 5, - }); - const selected = top[0]; - await fastify.prisma.mealSlot.create({ - data: { - planId: plan.id, - dayOfWeek: day, - meal, - recipeId: selected?.id ?? null, - alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) }, - }, - }); - plannedSlots.push({ day, meal, recipeId: selected?.id ?? null }); - } - } - - const ach = await evaluateAndPersist(fastify.prisma, userId); - if (ach.newlyUnlocked.length > 0) { - void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => { - /* notifica best-effort */ + const plan = await fastify.prisma.mealPlan.upsert({ + where: { userId_weekStart: { userId, weekStart } }, + create: { userId, weekStart }, + update: { generatedAt: new Date(), status: 'ACTIVE' }, }); - } - return reply.code(201).send({ - plan: { - id: plan.id, - weekStart: plan.weekStart.toISOString().slice(0, 10), - dailyTarget: baseDailyTarget, - fastingProtocol, - }, - newlyUnlocked: ach.newlyUnlocked, - }); - }); + // Wipe existing slots before regenerating, in case the plan already existed. + await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } }); + + const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = []; + for (let day = 0; day < 7; day++) { + const dayPlan = protocolPlanForDay(fastingProtocol, day); + // Giorno di digiuno completo (ESE_24): salta tutto. + if (dayPlan.kcalMultiplier === 0) continue; + + // Se non c'è un protocollo IF attivo e l'utente ha dichiarato + // mealsPerDay, lo usiamo per ridistribuire le quote per pasto. + const effectiveShare = + !fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share; + + // Sui giorni di allenamento aumentiamo le kcal per coprire l'EE + // della sessione (Ainsworth 2011 — vedi preferences/training.ts). + const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0; + const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra); + const dailyTarget = macrosForPhase({ + kcalTarget: dailyKcal, + weightKg: weightCurrentKg, + phase: phaseInt, + }); + + // Ricette scelte negli ultimi 2 giorni sono "recenti". + const recentlyConsumedIds: string[] = plannedSlots + .filter((s) => day - s.day <= 2 && s.recipeId) + .map((s) => s.recipeId!); + + for (const meal of MEALS) { + // Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot. + if (effectiveShare[meal] === 0) continue; + + const top = matchMeals({ + candidates, + meal, + phase: phaseInt, + excludedTags: exclusions, + bannedIngredientIds, + recentlyConsumedIds, + dailyTarget, + mealShare: effectiveShare, + maxPrepMinutes, + topN: 5, + }); + const selected = top[0]; + await fastify.prisma.mealSlot.create({ + data: { + planId: plan.id, + dayOfWeek: day, + meal, + recipeId: selected?.id ?? null, + alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) }, + }, + }); + plannedSlots.push({ day, meal, recipeId: selected?.id ?? null }); + } + } + + const ach = await evaluateAndPersist(fastify.prisma, userId); + if (ach.newlyUnlocked.length > 0) { + void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => { + /* notifica best-effort */ + }); + } + + return reply.code(201).send({ + plan: { + id: plan.id, + weekStart: plan.weekStart.toISOString().slice(0, 10), + dailyTarget: baseDailyTarget, + fastingProtocol, + }, + newlyUnlocked: ach.newlyUnlocked, + }); + }, + ); fastify.patch( '/me/meal-plans/slots/:slotId', - { preHandler: requireAuth() }, + { preHandler: [requireAuth(), requirePro()] }, async (request, reply) => { const slotId = (request.params as { slotId: string }).slotId; const body = request.body as { recipeId?: unknown }; @@ -322,7 +328,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { // automaticamente un nuovo digiuno. fastify.post( '/me/meal-plans/slots/:slotId/consumed', - { preHandler: requireAuth() }, + { preHandler: [requireAuth(), requirePro()] }, async (request, reply) => { const slotId = (request.params as { slotId: string }).slotId; const userId = request.user!.id; @@ -398,7 +404,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { // PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3. fastify.post( '/me/meal-plans/slots/:slotId/free-meal', - { preHandler: requireAuth() }, + { preHandler: [requireAuth(), requirePro()] }, async (request, reply) => { const slotId = (request.params as { slotId: string }).slotId; const userId = request.user!.id; @@ -424,7 +430,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { // conto dei pasti del giorno già scelti (coerenza dei macros). fastify.post( '/me/meal-plans/slots/:slotId/regenerate', - { preHandler: requireAuth() }, + { preHandler: [requireAuth(), requirePro()] }, async (request, reply) => { const slotId = (request.params as { slotId: string }).slotId; const userId = request.user!.id; diff --git a/apps/api/src/modules/tracking/check-in.routes.ts b/apps/api/src/modules/tracking/check-in.routes.ts index 43a150a..19b4605 100644 --- a/apps/api/src/modules/tracking/check-in.routes.ts +++ b/apps/api/src/modules/tracking/check-in.routes.ts @@ -5,6 +5,7 @@ import { dailyCheckInInputSchema } from '@ketopath/shared'; import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +import { requirePro } from '../../plugins/require-pro.js'; function todayDateOnly(): Date { const d = new Date(); @@ -54,36 +55,40 @@ export const checkInRoutes: FastifyPluginAsync = async (fastify) => { }; }); - fastify.post('/me/check-ins', { preHandler: requireAuth() }, async (request, reply) => { - const parsed = dailyCheckInInputSchema.safeParse(request.body); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); - } - const data = parsed.data; - const userId = request.user!.id; - const date = new Date(data.date); - date.setHours(0, 0, 0, 0); + fastify.post( + '/me/check-ins', + { preHandler: [requireAuth(), requirePro()] }, + async (request, reply) => { + const parsed = dailyCheckInInputSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); + } + const data = parsed.data; + const userId = request.user!.id; + const date = new Date(data.date); + date.setHours(0, 0, 0, 0); - const item = await fastify.prisma.dailyCheckIn.upsert({ - where: { userId_date: { userId, date } }, - create: { - userId, - date, - energy: data.energy ?? null, - sleep: data.sleep ?? null, - hunger: data.hunger ?? null, - mood: data.mood ?? null, - notes: data.notes ?? null, - }, - update: { - energy: data.energy ?? null, - sleep: data.sleep ?? null, - hunger: data.hunger ?? null, - mood: data.mood ?? null, - notes: data.notes ?? null, - }, - }); + const item = await fastify.prisma.dailyCheckIn.upsert({ + where: { userId_date: { userId, date } }, + create: { + userId, + date, + energy: data.energy ?? null, + sleep: data.sleep ?? null, + hunger: data.hunger ?? null, + mood: data.mood ?? null, + notes: data.notes ?? null, + }, + update: { + energy: data.energy ?? null, + sleep: data.sleep ?? null, + hunger: data.hunger ?? null, + mood: data.mood ?? null, + notes: data.notes ?? null, + }, + }); - return reply.code(201).send({ item: { id: item.id } }); - }); + return reply.code(201).send({ item: { id: item.id } }); + }, + ); }; diff --git a/apps/api/src/modules/tracking/export.routes.ts b/apps/api/src/modules/tracking/export.routes.ts index 950f8ae..9ef840a 100644 --- a/apps/api/src/modules/tracking/export.routes.ts +++ b/apps/api/src/modules/tracking/export.routes.ts @@ -6,6 +6,7 @@ import type { FastifyPluginAsync } from 'fastify'; import PDFDocument from 'pdfkit'; import { requireAuth } from '../../plugins/auth.js'; +import { requirePro } from '../../plugins/require-pro.js'; const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { day: 'numeric', @@ -14,151 +15,165 @@ const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { }); export const trackingExportRoutes: FastifyPluginAsync = async (fastify) => { - fastify.get('/me/tracking/export.pdf', { preHandler: requireAuth() }, async (request, reply) => { - const userId = request.user!.id; - const userEmail = request.user!.email ?? '—'; + fastify.get( + '/me/tracking/export.pdf', + { preHandler: [requireAuth(), requirePro()] }, + async (request, reply) => { + const userId = request.user!.id; + const userEmail = request.user!.email ?? '—'; - const [profile, entries, currentPlan] = await Promise.all([ - fastify.prisma.profile.findUnique({ where: { userId } }), - fastify.prisma.weightEntry.findMany({ - where: { userId }, - orderBy: { date: 'desc' }, - take: 30, - }), - fastify.prisma.mealPlan.findFirst({ - where: { userId, status: 'ACTIVE' }, - orderBy: { weekStart: 'desc' }, - include: { - slots: { - include: { - selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } }, + const [profile, entries, currentPlan] = await Promise.all([ + fastify.prisma.profile.findUnique({ where: { userId } }), + fastify.prisma.weightEntry.findMany({ + where: { userId }, + orderBy: { date: 'desc' }, + take: 30, + }), + fastify.prisma.mealPlan.findFirst({ + where: { userId, status: 'ACTIVE' }, + orderBy: { weekStart: 'desc' }, + include: { + slots: { + include: { + selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } }, + }, }, }, - }, - }), - ]); + }), + ]); - const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true }); - // Bufferizziamo i chunks per evitare race con fastify (vedi plan export). - const chunks: Buffer[] = []; - doc.on('data', (c: Buffer) => chunks.push(c)); - const done = new Promise((resolve, reject) => { - doc.on('end', () => resolve(Buffer.concat(chunks))); - doc.on('error', reject); - }); + const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true }); + // Bufferizziamo i chunks per evitare race con fastify (vedi plan export). + const chunks: Buffer[] = []; + doc.on('data', (c: Buffer) => chunks.push(c)); + const done = new Promise((resolve, reject) => { + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + }); - // ── Header ───────────────────────────────────────────────────────── - doc - .fillColor('#221d18') - .font('Helvetica-Bold') - .fontSize(22) - .text('KetoPath', { continued: true }) - .fillColor('#9a3f29') - .text('.'); - doc - .moveDown(0.2) - .fillColor('#3d3530') - .font('Helvetica') - .fontSize(9) - .text(`Sintesi tracking — ${userEmail}`) - .text(`Generato il ${ITALIAN_DATE.format(new Date())}`); - doc - .moveDown(1) - .strokeColor('#bdb6a3') - .lineWidth(0.5) - .moveTo(50, doc.y) - .lineTo(545, doc.y) - .stroke(); - - // ── Profile summary ──────────────────────────────────────────────── - doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO'); - doc.font('Helvetica').fontSize(10).fillColor('#3d3530'); - if (profile) { - const weightStart = Number(profile.weightStartKg); - const weightCurrent = Number(profile.weightCurrentKg); - const weightGoal = Number(profile.weightGoalKg); - const lostKg = weightStart - weightCurrent; + // ── Header ───────────────────────────────────────────────────────── doc - .moveDown(0.3) - .text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`) - .text( - `Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`, - ) - .text(`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`) - .text(`Fase corrente: ${profile.currentPhase}`); - } else { - doc.text('Profilo non ancora configurato.'); - } + .fillColor('#221d18') + .font('Helvetica-Bold') + .fontSize(22) + .text('KetoPath', { continued: true }) + .fillColor('#9a3f29') + .text('.'); + doc + .moveDown(0.2) + .fillColor('#3d3530') + .font('Helvetica') + .fontSize(9) + .text(`Sintesi tracking — ${userEmail}`) + .text(`Generato il ${ITALIAN_DATE.format(new Date())}`); + doc + .moveDown(1) + .strokeColor('#bdb6a3') + .lineWidth(0.5) + .moveTo(50, doc.y) + .lineTo(545, doc.y) + .stroke(); - // ── Weight history ───────────────────────────────────────────────── - doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO'); - if (entries.length === 0) { - doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.'); - } else { - doc.font('Helvetica').fontSize(9).fillColor('#3d3530'); - const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 }; - doc.moveDown(0.5).font('Helvetica-Bold').text('Data', colX.date, doc.y, { continued: false }); - const headerY = doc.y - 11; - doc.text('Peso (kg)', colX.weight, headerY); - doc.text('Energia', colX.energy, headerY); - doc.text('Sonno', colX.sleep, headerY); - doc.text('Fame', colX.hunger, headerY); - doc.font('Helvetica').moveDown(0.5); - for (const e of entries) { - const y = doc.y; - doc.text(e.date.toISOString().slice(0, 10), colX.date, y); - doc.text(Number(e.weightKg).toFixed(1), colX.weight, y); - doc.text(e.energy?.toString() ?? '—', colX.energy, y); - doc.text(e.sleep?.toString() ?? '—', colX.sleep, y); - doc.text(e.hunger?.toString() ?? '—', colX.hunger, y); - doc.moveDown(0.4); + // ── Profile summary ──────────────────────────────────────────────── + doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO'); + doc.font('Helvetica').fontSize(10).fillColor('#3d3530'); + if (profile) { + const weightStart = Number(profile.weightStartKg); + const weightCurrent = Number(profile.weightCurrentKg); + const weightGoal = Number(profile.weightGoalKg); + const lostKg = weightStart - weightCurrent; + doc + .moveDown(0.3) + .text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`) + .text( + `Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`, + ) + .text( + `Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`, + ) + .text(`Fase corrente: ${profile.currentPhase}`); + } else { + doc.text('Profilo non ancora configurato.'); } - } - // ── Current plan summary ─────────────────────────────────────────── - doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PIANO CORRENTE'); - doc.font('Helvetica').fontSize(10).fillColor('#3d3530'); - if (!currentPlan) { - doc.moveDown(0.3).text('Nessun piano attivo.'); - } else { - const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null); - const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0); - const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0); - const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0); - const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0); - const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1; - const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length; + // ── Weight history ───────────────────────────────────────────────── + doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO'); + if (entries.length === 0) { + doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.'); + } else { + doc.font('Helvetica').fontSize(9).fillColor('#3d3530'); + const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 }; + doc + .moveDown(0.5) + .font('Helvetica-Bold') + .text('Data', colX.date, doc.y, { continued: false }); + const headerY = doc.y - 11; + doc.text('Peso (kg)', colX.weight, headerY); + doc.text('Energia', colX.energy, headerY); + doc.text('Sonno', colX.sleep, headerY); + doc.text('Fame', colX.hunger, headerY); + doc.font('Helvetica').moveDown(0.5); + for (const e of entries) { + const y = doc.y; + doc.text(e.date.toISOString().slice(0, 10), colX.date, y); + doc.text(Number(e.weightKg).toFixed(1), colX.weight, y); + doc.text(e.energy?.toString() ?? '—', colX.energy, y); + doc.text(e.sleep?.toString() ?? '—', colX.sleep, y); + doc.text(e.hunger?.toString() ?? '—', colX.hunger, y); + doc.moveDown(0.4); + } + } + + // ── Current plan summary ─────────────────────────────────────────── doc - .moveDown(0.3) - .text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`) - .text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`) + .moveDown(1) + .fillColor('#221d18') + .font('Helvetica-Bold') + .fontSize(11) + .text('PIANO CORRENTE'); + doc.font('Helvetica').fontSize(10).fillColor('#3d3530'); + if (!currentPlan) { + doc.moveDown(0.3).text('Nessun piano attivo.'); + } else { + const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null); + const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0); + const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0); + const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0); + const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0); + const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1; + const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length; + doc + .moveDown(0.3) + .text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`) + .text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`) + .text( + `Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`, + ); + } + + // ── Footer ───────────────────────────────────────────────────────── + doc + .moveDown(2) + .strokeColor('#bdb6a3') + .lineWidth(0.5) + .moveTo(50, doc.y) + .lineTo(545, doc.y) + .stroke(); + doc + .moveDown(0.5) + .fontSize(8) + .fillColor('#7a7060') .text( - `Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`, + 'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.', ); - } - // ── Footer ───────────────────────────────────────────────────────── - doc - .moveDown(2) - .strokeColor('#bdb6a3') - .lineWidth(0.5) - .moveTo(50, doc.y) - .lineTo(545, doc.y) - .stroke(); - doc - .moveDown(0.5) - .fontSize(8) - .fillColor('#7a7060') - .text( - 'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.', - ); - - doc.end(); - const pdfBuffer = await done; - return reply - .header('Content-Type', 'application/pdf') - .header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"') - .header('Content-Length', String(pdfBuffer.length)) - .send(pdfBuffer); - }); + doc.end(); + const pdfBuffer = await done; + return reply + .header('Content-Type', 'application/pdf') + .header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"') + .header('Content-Length', String(pdfBuffer.length)) + .send(pdfBuffer); + }, + ); }; diff --git a/apps/api/src/modules/tracking/fast.routes.ts b/apps/api/src/modules/tracking/fast.routes.ts index 8258879..ccf8955 100644 --- a/apps/api/src/modules/tracking/fast.routes.ts +++ b/apps/api/src/modules/tracking/fast.routes.ts @@ -6,6 +6,7 @@ import { import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +import { requirePro } from '../../plugins/require-pro.js'; import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js'; export const fastRoutes: FastifyPluginAsync = async (fastify) => { @@ -29,91 +30,103 @@ export const fastRoutes: FastifyPluginAsync = async (fastify) => { }; }); - fastify.post('/me/fast-events', { preHandler: requireAuth() }, async (request, reply) => { - const parsed = fastEventStartSchema.safeParse(request.body); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); - } - const data = parsed.data; - const event = await fastify.prisma.fastEvent.create({ - data: { - userId: request.user!.id, - protocol: data.protocol, - startedAt: data.startedAt ?? new Date(), - targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol], - }, - }); - return reply.code(201).send({ event: { id: event.id } }); - }); - - fastify.patch('/me/fast-events/:id', { preHandler: requireAuth() }, async (request, reply) => { - const id = (request.params as { id: string }).id; - const parsed = fastEventUpdateSchema.safeParse(request.body); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); - } - const data = parsed.data; - // Garantisce che l'utente possa aggiornare solo i propri eventi. - const owned = await fastify.prisma.fastEvent.findFirst({ - where: { id, userId: request.user!.id }, - select: { id: true }, - }); - if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' }); - - // Costruiamo l'oggetto data omettendo le chiavi non fornite, perché con - // `exactOptionalPropertyTypes` Prisma rifiuta `undefined` esplicito. - const updateData: Parameters[0]['data'] = {}; - if (data.endedAt) updateData.endedAt = data.endedAt; - if (data.status) updateData.status = data.status; - if (data.symptoms) updateData.symptoms = JSON.stringify(data.symptoms); - if (data.notes != null) updateData.notes = data.notes; - - const event = await fastify.prisma.fastEvent.update({ - where: { id }, - data: updateData, - }); - let newlyUnlocked: string[] = []; - if (event.status === 'COMPLETED') { - const ach = await evaluateAndPersist(fastify.prisma, request.user!.id); - newlyUnlocked = ach.newlyUnlocked; - if (newlyUnlocked.length > 0) { - void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => { - /* notifica best-effort */ - }); + fastify.post( + '/me/fast-events', + { preHandler: [requireAuth(), requirePro()] }, + async (request, reply) => { + const parsed = fastEventStartSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); } - } - return { event: { id: event.id, status: event.status }, newlyUnlocked }; - }); + const data = parsed.data; + const event = await fastify.prisma.fastEvent.create({ + data: { + userId: request.user!.id, + protocol: data.protocol, + startedAt: data.startedAt ?? new Date(), + targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol], + }, + }); + return reply.code(201).send({ event: { id: event.id } }); + }, + ); + + fastify.patch( + '/me/fast-events/:id', + { preHandler: [requireAuth(), requirePro()] }, + async (request, reply) => { + const id = (request.params as { id: string }).id; + const parsed = fastEventUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); + } + const data = parsed.data; + // Garantisce che l'utente possa aggiornare solo i propri eventi. + const owned = await fastify.prisma.fastEvent.findFirst({ + where: { id, userId: request.user!.id }, + select: { id: true }, + }); + if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' }); + + // Costruiamo l'oggetto data omettendo le chiavi non fornite, perché con + // `exactOptionalPropertyTypes` Prisma rifiuta `undefined` esplicito. + const updateData: Parameters[0]['data'] = {}; + if (data.endedAt) updateData.endedAt = data.endedAt; + if (data.status) updateData.status = data.status; + if (data.symptoms) updateData.symptoms = JSON.stringify(data.symptoms); + if (data.notes != null) updateData.notes = data.notes; + + const event = await fastify.prisma.fastEvent.update({ + where: { id }, + data: updateData, + }); + let newlyUnlocked: string[] = []; + if (event.status === 'COMPLETED') { + const ach = await evaluateAndPersist(fastify.prisma, request.user!.id); + newlyUnlocked = ach.newlyUnlocked; + if (newlyUnlocked.length > 0) { + void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => { + /* notifica best-effort */ + }); + } + } + return { event: { id: event.id, status: event.status }, newlyUnlocked }; + }, + ); // PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a // `until` (default: domani alle 06:00 nel fuso del server). Idempotente: // body vuoto = pausa fino a domattina; { until: null } = riprende subito. - fastify.post('/me/fasting/pause', { preHandler: requireAuth() }, async (request, reply) => { - const userId = request.user!.id; - const body = (request.body ?? {}) as { until?: string | null }; - let until: Date | null = null; - if (body.until === null) { - until = null; - } else if (typeof body.until === 'string') { - const d = new Date(body.until); - if (Number.isNaN(d.getTime())) { - return reply.code(400).send({ error: 'invalid_until' }); + fastify.post( + '/me/fasting/pause', + { preHandler: [requireAuth(), requirePro()] }, + async (request, reply) => { + const userId = request.user!.id; + const body = (request.body ?? {}) as { until?: string | null }; + let until: Date | null = null; + if (body.until === null) { + until = null; + } else if (typeof body.until === 'string') { + const d = new Date(body.until); + if (Number.isNaN(d.getTime())) { + return reply.code(400).send({ error: 'invalid_until' }); + } + until = d; + } else { + // default: domani alle 06:00 locali (server) + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(6, 0, 0, 0); + until = tomorrow; } - until = d; - } else { - // default: domani alle 06:00 locali (server) - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(6, 0, 0, 0); - until = tomorrow; - } - const user = await fastify.prisma.user.update({ - where: { id: userId }, - data: { fastingPausedUntil: until }, - select: { fastingPausedUntil: true }, - }); - return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null }; - }); + const user = await fastify.prisma.user.update({ + where: { id: userId }, + data: { fastingPausedUntil: until }, + select: { fastingPausedUntil: true }, + }); + return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null }; + }, + ); // PRD §5.3 — pasto di rottura intelligente. Per digiuni > 24h propone 3 // ricette facili da digerire: prep <= 20 min, kcal <= 350, nessun ingrediente diff --git a/apps/api/src/modules/tracking/weight.routes.ts b/apps/api/src/modules/tracking/weight.routes.ts index 4b20c97..fa62f31 100644 --- a/apps/api/src/modules/tracking/weight.routes.ts +++ b/apps/api/src/modules/tracking/weight.routes.ts @@ -2,6 +2,7 @@ import { weightEntryInputSchema } from '@ketopath/shared'; import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +import { requirePro } from '../../plugins/require-pro.js'; import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js'; export const weightRoutes: FastifyPluginAsync = async (fastify) => { @@ -26,48 +27,52 @@ export const weightRoutes: FastifyPluginAsync = async (fastify) => { }; }); - fastify.post('/me/weight-entries', { preHandler: requireAuth() }, async (request, reply) => { - const parsed = weightEntryInputSchema.safeParse(request.body); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); - } - const data = parsed.data; - const userId = request.user!.id; + fastify.post( + '/me/weight-entries', + { preHandler: [requireAuth(), requirePro()] }, + async (request, reply) => { + const parsed = weightEntryInputSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); + } + const data = parsed.data; + const userId = request.user!.id; - const entry = await fastify.prisma.weightEntry.upsert({ - where: { userId_date: { userId, date: data.date } }, - create: { - userId, - date: data.date, - weightKg: String(data.weightKg), - measurements: data.measurements ? JSON.stringify(data.measurements) : null, - notes: data.notes ?? null, - energy: data.energy ?? null, - sleep: data.sleep ?? null, - hunger: data.hunger ?? null, - photos: data.photos ?? [], - }, - update: { - weightKg: String(data.weightKg), - measurements: data.measurements ? JSON.stringify(data.measurements) : null, - notes: data.notes ?? null, - energy: data.energy ?? null, - sleep: data.sleep ?? null, - hunger: data.hunger ?? null, - photos: data.photos ?? [], - }, - }); - - const ach = await evaluateAndPersist(fastify.prisma, userId); - if (ach.newlyUnlocked.length > 0) { - void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => { - /* notifica best-effort */ + const entry = await fastify.prisma.weightEntry.upsert({ + where: { userId_date: { userId, date: data.date } }, + create: { + userId, + date: data.date, + weightKg: String(data.weightKg), + measurements: data.measurements ? JSON.stringify(data.measurements) : null, + notes: data.notes ?? null, + energy: data.energy ?? null, + sleep: data.sleep ?? null, + hunger: data.hunger ?? null, + photos: data.photos ?? [], + }, + update: { + weightKg: String(data.weightKg), + measurements: data.measurements ? JSON.stringify(data.measurements) : null, + notes: data.notes ?? null, + energy: data.energy ?? null, + sleep: data.sleep ?? null, + hunger: data.hunger ?? null, + photos: data.photos ?? [], + }, }); - } - return reply.code(201).send({ - entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) }, - newlyUnlocked: ach.newlyUnlocked, - }); - }); + const ach = await evaluateAndPersist(fastify.prisma, userId); + if (ach.newlyUnlocked.length > 0) { + void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => { + /* notifica best-effort */ + }); + } + + return reply.code(201).send({ + entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) }, + newlyUnlocked: ach.newlyUnlocked, + }); + }, + ); }; diff --git a/apps/api/src/plugins/require-pro.ts b/apps/api/src/plugins/require-pro.ts new file mode 100644 index 0000000..5eb6615 --- /dev/null +++ b/apps/api/src/plugins/require-pro.ts @@ -0,0 +1,29 @@ +import { isProActive } from '@ketopath/shared'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { ensureSubscription, toSnapshot } from '../modules/billing/service.js'; + +/** + * ADR 0004 — middleware "paywall": le rotte/azioni Pro-only lo applicano + * dopo `requireAuth()`. Risponde 402 (`payment_required`) se l'utente non + * ha accesso Pro (trial scaduto, canceled, expired). + * + * Side effect benefico: `ensureSubscription` crea il record di trial se non + * esiste. Significa che chiamando una rotta gated, un utente nuovo riceve + * automaticamente il trial — non servono webhook on-signup. + */ +export function requirePro() { + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + if (!request.user) { + return reply.code(401).send({ error: 'unauthorized' }); + } + const sub = await ensureSubscription( + request.server.prisma, + request.user.id, + request.user.createdAt, + ); + if (!isProActive(toSnapshot(sub))) { + return reply.code(402).send({ error: 'payment_required', kind: 'pro_required' }); + } + }; +} diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 30c4c3a..ac87952 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -15,6 +15,7 @@ "viewShopping": "Lista della spesa", "viewTracking": "Pesata & misure", "viewFasting": "Digiuno intermittente", + "viewBilling": "Abbonamento & fatture", "lettura": "Continua la lettura", "inizia": "Inizia", "greeting": "Buongiorno, {name}.", @@ -710,5 +711,57 @@ "email_required": "Email assente sul tuo account.", "api_error": "Errore di rete. Riprova fra poco." } + }, + "Billing": { + "title": "Abbonamento", + "subtitle": "Trenta giorni di prova gratuiti, senza carta. Poi un piano semplice — mensile o annuale — che si gestisce in autonomia dal portale Stripe.", + "currentStatus": "Stato attuale", + "actions": "Azioni", + "trialActive": "Sei in prova ({days} giorni rimasti).", + "trialActiveBody": "Hai accesso completo a piani, tracking, digiuno e foto progress fino al {endsAt}. Allo scadere puoi continuare a leggere lo storico — ma per nuove pesate, piani e foto serve l'abbonamento.", + "trialExpired": "La prova è scaduta.", + "trialExpiredBody": "Lo storico resta consultabile. Per ricominciare a generare piani, registrare pesate e completare digiuni, attiva un abbonamento.", + "proActive": "Abbonamento attivo.", + "proActiveBody": "Piano {interval}, prossimo rinnovo il {renewsAt}. Tutto sbloccato.", + "pastDue": "Pagamento in sospeso.", + "pastDueBody": "L'ultimo addebito è fallito. Stripe sta riprovando: aggiorna la carta dal portale per evitare interruzioni.", + "canceling": "Disdetta in corso.", + "cancelingBody": "L'abbonamento resta valido fino al {endsAt}. Puoi riattivarlo in qualsiasi momento dal portale.", + "canceled": "Abbonamento disdetto.", + "canceledBody": "Lo storico resta tuo e consultabile. Quando vuoi rientrare, basta un nuovo upgrade.", + "noSubscription": "Nessun abbonamento attivo.", + "intervalMonth": "mensile", + "intervalYear": "annuale", + "upgradeMonthly": "Abbonati — €9,90 / mese", + "upgradeYearly": "Abbonati — €89 / anno (≈25% di sconto)", + "managePortal": "Gestisci abbonamento", + "opening": "Apertura…", + "checkoutHint": "Verrai reindirizzato al pagamento sicuro su Stripe.", + "notConfigured": "Pagamenti non ancora attivati. La tua prova continua: nessuna interruzione.", + "errorLoading": "Impossibile caricare lo stato dell'abbonamento.", + "whatYouGet": "Cosa è incluso", + "benefit1Title": "Piani settimanali infiniti", + "benefit1Body": "Genera quanti piani vuoi, riadattati alla fase, alle preferenze e alle esclusioni alimentari. Con riadattamento automatico se cambi peso o protocollo.", + "benefit2Title": "Tracking completo", + "benefit2Body": "Pesate quotidiane, check-in soggettivi, foto progress, digiuno con timer e milestone. Tutto cifrato a riposo (art. 9 GDPR).", + "benefit3Title": "Export PDF per il medico", + "benefit3Body": "Un documento sintetico — peso, misure, aderenza — pronto da inviare al nutrizionista o consultare in studio.", + "trialBannerTitle": "Sei in prova", + "trialBannerBody": "{days} giorni rimasti. Tutto sbloccato fino al {endsAt}.", + "trialBannerCta": "Vai all'abbonamento", + "trialEndedBannerTitle": "La prova è scaduta", + "trialEndedBannerBody": "Per generare nuovi piani e registrare pesate serve l'abbonamento.", + "trialEndedBannerCta": "Abbonati", + "paywallTitle": "Funzione Pro", + "paywallBody": "Questa azione richiede un abbonamento attivo.", + "paywallCta": "Vai all'abbonamento", + "navLabel": "Abbonamento", + "error": { + "billing_not_configured": "Il sistema di pagamento non è ancora configurato. Riprova più tardi.", + "no_stripe_customer": "Non hai ancora un abbonamento attivo. Avvia prima il checkout.", + "api_error_400": "Richiesta non valida.", + "api_error_401": "Devi accedere per gestire l'abbonamento.", + "api_error_500": "Errore del server. Riprova fra poco." + } } } diff --git a/apps/web/src/app/[locale]/billing/actions.ts b/apps/web/src/app/[locale]/billing/actions.ts new file mode 100644 index 0000000..8c25c8e --- /dev/null +++ b/apps/web/src/app/[locale]/billing/actions.ts @@ -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 { + 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 }; +} diff --git a/apps/web/src/app/[locale]/billing/billing-actions-bar.tsx b/apps/web/src/app/[locale]/billing/billing-actions-bar.tsx new file mode 100644 index 0000000..2d3964b --- /dev/null +++ b/apps/web/src/app/[locale]/billing/billing-actions-bar.tsx @@ -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 ( + + ); + } + + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/app/[locale]/billing/page.tsx b/apps/web/src/app/[locale]/billing/page.tsx new file mode 100644 index 0000000..81579bd --- /dev/null +++ b/apps/web/src/app/[locale]/billing/page.tsx @@ -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 ; +} + +function BillingPageContent({ status }: { status: BillingStatus | null }) { + const t = useTranslations('Billing'); + + if (!status) { + return ( +
+
+ +
+

{t('errorLoading')}

+
+
+
+ ); + } + + const { subscription, derived, configured } = status; + const hasStripeCustomer = subscription?.stripePriceId != null; + + return ( +
+
+ +
+ +
+
+ + VIII + +
+

+ Capitolo VIII — Abbonamento +

+
+

+ + {t('title')} + . + +

+

+ {t('subtitle')} +

+ +
+ +
+
+

{t('currentStatus')}

+ +
+ +
+ +
+ +
+

{t('whatYouGet')}

+
+ + + +
+
+
+
+
+ ); +} + +function StatusBlock({ + derived, + subscription, +}: { + derived: BillingStatus['derived']; + subscription: BillingStatus['subscription']; +}) { + const t = useTranslations('Billing'); + + switch (derived.kind) { + case 'trial': + return ( +
+

+ {t('trialActive', { days: derived.trialDaysRemaining ?? 0 })} +

+

+ {t('trialActiveBody', { + endsAt: derived.accessEndsAt + ? ITALIAN_DATE.format(new Date(derived.accessEndsAt)) + : '—', + })} +

+
+ ); + case 'trial_expired': + return ( +
+

+ {t('trialExpired')} +

+

+ {t('trialExpiredBody')} +

+
+ ); + case 'active': + return ( +
+

+ {t('proActive')} +

+

+ {t('proActiveBody', { + interval: + subscription?.interval === 'YEAR' + ? t('intervalYear') + : subscription?.interval === 'MONTH' + ? t('intervalMonth') + : '—', + renewsAt: derived.accessEndsAt + ? ITALIAN_DATE.format(new Date(derived.accessEndsAt)) + : '—', + })} +

+
+ ); + case 'past_due': + return ( +
+

+ {t('pastDue')} +

+

+ {t('pastDueBody')} +

+
+ ); + case 'canceling': + return ( +
+

+ {t('canceling')} +

+

+ {t('cancelingBody', { + endsAt: derived.accessEndsAt + ? ITALIAN_DATE.format(new Date(derived.accessEndsAt)) + : '—', + })} +

+
+ ); + case 'canceled': + return ( +
+

+ {t('canceled')} +

+

+ {t('canceledBody')} +

+
+ ); + case 'no_subscription': + default: + return ( +

+ {t('noSubscription')} +

+ ); + } +} + +function Benefit({ num, titleKey, bodyKey }: { num: string; titleKey: string; bodyKey: string }) { + const t = useTranslations('Billing'); + return ( +
+

{num}

+

+ {t(titleKey)} +

+

{t(bodyKey)}

+
+ ); +} diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index a66621e..5c54207 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -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 ; + return ; } -function HomeContent({ userName }: { userName: string | null }) { +function HomeContent({ + userName, + billing, +}: { + userName: string | null; + billing: BillingStatus | null; +}) { return (
- {userName ? : } + {userName ? ( + + ) : ( + + )}
); @@ -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 (
@@ -848,6 +867,8 @@ function SignedInDashboard({ userName }: { userName: string }) { {t('tagline')}

+ + {/* Nav asimmetrica: card grande "/plan" a sinistra, le altre 4 in colonna a destra */}
+
  • @@ -957,3 +979,59 @@ function NavItem({ ); } + +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 ( + + ); + } + + if (derived.kind === 'trial_expired' || derived.kind === 'past_due') { + return ( + + ); + } + + return null; +} diff --git a/docs/decisions/0004-payment-provider.md b/docs/decisions/0004-payment-provider.md new file mode 100644 index 0000000..38678cd --- /dev/null +++ b/docs/decisions/0004-payment-provider.md @@ -0,0 +1,107 @@ +# ADR 0004 — Payment provider e modello di abbonamento + +- **Status**: accepted +- **Date**: 2026-05-07 +- **Decision makers**: Luciano (PO), Claude +- **Supersedes**: scelta aperta in `CLAUDE.md` ("Stripe vs Lemon Squeezy") + +## Contesto + +KetoPath chiude il primo ciclo di feature MVP (onboarding, piani settimanali, tracking, digiuno, lista spesa, achievement). Per portare l'app sul mercato serve un sistema di monetizzazione. + +Tre vincoli forti dal contesto del progetto: + +1. **Mercato target IT/EU**: tutti gli utenti pagano in EUR e devono ricevere fatture / scontrini compatibili con la normativa fiscale italiana. +2. **GDPR / dati di salute (art. 9)**: il provider può vedere solo email, nome, dati di pagamento. Non deve mai accedere a peso, foto, sintomi. +3. **Operatori limitati**: il PO è un singolo developer, non vuole mettere su una struttura amministrativa per gestire IVA EU OSS, fatturazione elettronica, regolarizzazioni. + +## Decisione + +### Provider: Stripe (con Stripe Tax + Customer Portal) + +- **Stripe** come gateway di pagamento e gestore abbonamenti. +- **Stripe Tax** abilitato per il calcolo automatico di IVA EU (0,5% del fatturato per il servizio di Stripe Tax). +- **Stripe Customer Portal** per la gestione self-service di abbonamento e fatture (l'utente cambia piano, aggiorna carta, cancella, scarica fatture in autonomia). +- **Stripe Checkout** (hosted) per il primo upgrade — niente form di pagamento custom, riduce il perimetro PCI. + +### Modello di abbonamento: free 30 giorni → Pro (no carta richiesta) + +- **Trial di 30 giorni gratuiti** dalla data di registrazione, **senza carta richiesta**. Allinea il momento del paywall alla fine della fase INTENSIVE (PRD §5.1) — quando l'utente ha già visto i primi risultati, ha accumulato dati storici e ha alta motivazione a continuare. +- **Stato `TRIALING`** nel DB durante i primi 30 giorni; nessuna interazione con Stripe finché l'utente non avvia il checkout. +- **Allo scadere del trial**: l'utente passa a `EXPIRED`. L'app non viene "spenta": modalità sola-lettura (vedi sezione "Gating"). +- **Piano Pro**: `€9,90/mese` o `€89/anno` (≈25% sconto pagando 12 mesi). I `priceId` sono in env, non hard-coded. +- **Cancellazione**: gestita interamente via Customer Portal. Lo stato in DB è `CANCEL_AT_PERIOD_END` finché la subscription non scade davvero, poi `CANCELED`. + +### Gating + +| Area | Trial | Pro | Expired (post-trial / scaduto) | +| --------------------------- | ----- | --- | ------------------------------- | +| Profilo, prefs | ✅ | ✅ | ✅ (lettura + modifica) | +| Storico tracking | ✅ | ✅ | ✅ (lettura) | +| Storico piani | ✅ | ✅ | ✅ (lettura) | +| Lista spesa | ✅ | ✅ | ✅ (lettura, archiviata) | +| **Generazione nuovo piano** | ✅ | ✅ | ❌ (paywall) | +| **Nuova pesata / check-in** | ✅ | ✅ | ❌ (paywall) | +| **Foto progress** | ✅ | ✅ | ❌ (paywall) | +| **Avvio digiuno** | ✅ | ✅ | ❌ (paywall) | +| **Export PDF** | ✅ | ✅ | ❌ (paywall) | +| **Achievements** | ✅ | ✅ | ✅ (lettura, no nuovi sblocchi) | + +L'utente Expired non perde dati — può accedere allo storico e riattivare l'abbonamento in qualsiasi momento. Le rotte/azioni gated rispondono `402 payment_required` lato API e mostrano un componente `` lato web. + +## Conseguenze + +### Positive + +- **IVA UE gestita**: con Stripe Tax l'IVA viene calcolata automaticamente sulla base del paese del cliente; le fatture le emette Stripe (per il Tax-ID) o le emettiamo noi a partire dai dati raccolti — il PO non deve registrarsi al MOSS. +- **Customer Portal pronto**: zero codice per upgrade/downgrade/cancel/aggiorna carta — tutto delegato all'UI Stripe. +- **Fee EU competitivo**: 1,5% + €0,25 per transazione SEPA/EU, contro il ≈5%+€0,50 di Lemon Squeezy. Su €10/mese, Stripe è ≈ €0,40 di fee, LS sarebbe ≈ €1,00. +- **Webhook pattern noto**: pattern molto documentato, libreria `stripe-node` mantenuta e tipata. +- **Trial nativo**: `subscription.trial_end` di Stripe è perfetto per gestire il free 30gg quando l'utente passa al pagato (carta inserita, trial conta come parte della sub). + +### Negative + +- **PO è "merchant of record"**: a differenza di Lemon Squeezy, Stripe non si interpone — il PO deve avere partita IVA italiana e dichiarare correttamente i ricavi. Sopra €10k/anno serve la registrazione MOSS o Stripe Tax (IVA OSS) — gestibile, ma non zero-effort. +- **Webhook = stato di verità**: tutta la logica di subscription deve passare dal webhook firmato (`stripe-signature` HMAC). Se il webhook fallisce, lo stato in DB diverge. +- **Vendor lock-in moderato**: lo stato `subscriptions` resta in DB nostro, ma `customerId` e `subscriptionId` sono Stripe-specifici. Cambiare provider richiede re-onboarding dei clienti. + +## Alternative considerate + +| Opzione | Motivo del NO | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Lemon Squeezy | MoR comodo per IVA, ma fee 3-4× superiore. Su volumi MVP la differenza è gestibile, ma scala male appena cresci. Tenuta come backup se Stripe diventasse problematico. | +| Paddle | Anche MoR, simile a LS. Setup più burocratico e UX di checkout meno polished. Niente vantaggio sostanziale rispetto a LS. | +| GoCardless | Solo SEPA, niente carte → friction sul cliente medio italiano che paga con carta. Ottimo per B2B, non per consumer. | +| Mollie | Buon player EU, ma ecosistema npm molto più piccolo e tooling meno maturo (no portal cliente equivalente). Ha senso solo se IVA Stripe diventasse problematica. | +| Crypto-only | Off-topic per il segmento — bassa adozione tra il target keto/wellness IT. | + +### Modelli di gating considerati + +| Modello | Motivo della scelta | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Free 30gg → Pro** ✅ | Allinea il paywall al punto di massima adesione (fine Phase 1 = utente ha già visto risultati). Lock-in psicologico: 30gg di dati storici creano dipendenza positiva. | +| Freemium con limiti | Più complesso da bilanciare (cosa free, cosa Pro), aggiunge debito di prodotto in ogni feature. Rinviato a v2 se serve acquisition organica. | +| Trial 14gg | Mismatch col dominio — l'adattamento keto dura 2-3 settimane, 14 giorni sono pochi per vedere risultati e converti in modo informato. | +| Pay-from-day-1 | Friction massima all'ingresso, riduce drasticamente l'acquisition. | + +## Implementazione + +Tracciata in 6 fasi (questo ADR copre la fase 0): + +- **Fase 0** (questo ADR + aggiornamento `CLAUDE.md`) +- **Fase 1**: schema Prisma + migration `add_subscriptions` +- **Fase 2**: modulo `@ketopath/api/billing` (env, service, routes checkout/portal/status, webhook firmato) +- **Fase 3**: helper `isProActive(userId)` lato shared + middleware paywall sulle rotte/azioni gated +- **Fase 4**: pagina `/billing` (stato, scadenza, link upgrade/portal), banner trial in dashboard, componente `` +- **Fase 5**: test unit (`isProActive`, evaluator stato), test integrazione webhook +- **Fase 6**: configurazione live — Stripe account + Stripe Tax + price IDs in env, deploy webhook su URL stabile + +## Vincoli operativi da rispettare + +- **Webhook = source of truth**: ogni mutazione di stato subscription DEVE passare dal webhook. Niente "fai-da-te" lato frontend dopo il checkout — si redirige a una pagina "in elaborazione" e si attende il webhook. +- **Firma webhook obbligatoria**: il body raw del webhook va verificato con `stripe.webhooks.constructEvent(rawBody, signature, secret)`. Mai accettare un evento senza firma valida. +- **Idempotenza**: il webhook può ricevere lo stesso evento più volte (Stripe ritenta in caso di 5xx). Ogni handler deve essere idempotente — la chiave naturale `event.id` deve essere registrata in DB per scartare i duplicati (tabella `BillingWebhookEvent`). +- **Niente PII oltre il necessario**: a Stripe inviamo solo `email` e `userId` come `metadata`. **Mai** weight, foto, sintomi, achievements. +- **Trial senza carta**: il trial di 30gg è puramente lato DB (status `TRIALING`, `trialEndsAt`). Nessuna interazione con Stripe finché l'utente non clicca "Upgrade" volontariamente. +- **`STRIPE_SECRET_KEY` e `STRIPE_WEBHOOK_SECRET`**: caricati solo lato `apps/api` (mai `apps/web`). Il frontend riceve un `client secret` solo dal backend. +- **PCI scope minimo**: usiamo solo Stripe Checkout hosted — nessun campo carta passa mai per i nostri server. diff --git a/packages/db/prisma/migrations/20260507091227_add_subscriptions/migration.sql b/packages/db/prisma/migrations/20260507091227_add_subscriptions/migration.sql new file mode 100644 index 0000000..33014b2 --- /dev/null +++ b/packages/db/prisma/migrations/20260507091227_add_subscriptions/migration.sql @@ -0,0 +1,53 @@ +-- CreateEnum +CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCEL_AT_PERIOD_END', 'CANCELED', 'EXPIRED'); + +-- CreateEnum +CREATE TYPE "BillingInterval" AS ENUM ('MONTH', 'YEAR'); + +-- CreateTable +CREATE TABLE "subscriptions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "status" "SubscriptionStatus" NOT NULL, + "trial_ends_at" TIMESTAMP(3) NOT NULL, + "stripe_customer_id" TEXT, + "stripe_subscription_id" TEXT, + "stripe_price_id" TEXT, + "current_period_end" TIMESTAMP(3), + "interval" "BillingInterval", + "cancel_at_period_end" BOOLEAN NOT NULL DEFAULT false, + "ended_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "billing_webhook_events" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "processed_at" TIMESTAMP(3), + "payload" JSONB NOT NULL, + + CONSTRAINT "billing_webhook_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "subscriptions_user_id_key" ON "subscriptions"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "subscriptions_stripe_customer_id_key" ON "subscriptions"("stripe_customer_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "subscriptions_stripe_subscription_id_key" ON "subscriptions"("stripe_subscription_id"); + +-- CreateIndex +CREATE INDEX "subscriptions_status_idx" ON "subscriptions"("status"); + +-- CreateIndex +CREATE INDEX "billing_webhook_events_type_idx" ON "billing_webhook_events"("type"); + +-- AddForeignKey +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a175d90..d1222e6 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -76,6 +76,23 @@ enum MealPlanStatus { ARCHIVED } +// PRD §16.3 / ADR 0004 — stato dell'abbonamento. Mappato 1:1 sullo stato +// Stripe ma mantenuto in DB come source-of-truth applicativo (l'API non +// chiama Stripe a ogni request — usa il valore qui). +enum SubscriptionStatus { + TRIALING // free 30gg post-signup, no carta richiesta + ACTIVE // pagante, in regola + PAST_DUE // pagamento fallito, in retry da Stripe (grace period) + CANCEL_AT_PERIOD_END // canceled ma valido fino a fine periodo corrente + CANCELED // canceled ed expired + EXPIRED // trial scaduto senza upgrade +} + +enum BillingInterval { + MONTH + YEAR +} + model User { id String @id @default(cuid()) email String @unique @@ -97,6 +114,7 @@ model User { profile Profile? preferences Preferences? + subscription Subscription? sessions Session[] accounts Account[] weightEntries WeightEntry[] @@ -109,6 +127,49 @@ model User { @@map("users") } +// ADR 0004 — abbonamento dell'utente. 1:1 con User. Lo stato è la verità +// applicativa: il webhook Stripe lo aggiorna, ma le route che gating-ano +// le feature leggono solo da qui (niente roundtrip Stripe). +model Subscription { + id String @id @default(cuid()) + userId String @unique @map("user_id") + status SubscriptionStatus + // Trial nativo (30gg post-signup, no carta). Sempre valorizzato. + trialEndsAt DateTime @map("trial_ends_at") + // Stripe: presente solo dopo il primo upgrade (checkout completato). + stripeCustomerId String? @unique @map("stripe_customer_id") + stripeSubscriptionId String? @unique @map("stripe_subscription_id") + stripePriceId String? @map("stripe_price_id") + // Periodo corrente (Pro). Riflette `current_period_end` di Stripe. + currentPeriodEnd DateTime? @map("current_period_end") + interval BillingInterval? + // Se `cancelAtPeriodEnd = true`, la sub è ancora ACTIVE ma non si rinnoverà. + cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end") + // Quando lo status è effettivamente expired/canceled, archiviamo la data. + endedAt DateTime? @map("ended_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([status]) + @@map("subscriptions") +} + +// ADR 0004 — registro idempotenza dei webhook Stripe. Stripe ritenta gli +// eventi falliti, ricezione multipla è normale: scartiamo i duplicati su +// `event.id` invece di applicare due volte lo stesso aggiornamento. +model BillingWebhookEvent { + id String @id // event.id di Stripe (evt_...) + type String + receivedAt DateTime @default(now()) @map("received_at") + processedAt DateTime? @map("processed_at") + payload Json + + @@index([type]) + @@map("billing_webhook_events") +} + // PRD §6 — sistema achievement: badge sbloccati man mano che l'utente // raggiunge milestone (prima pesata, primo digiuno, primo piano, ecc.). // `key` identifica univocamente l'achievement (vedi packages/shared). diff --git a/packages/shared/src/billing/pro-status.test.ts b/packages/shared/src/billing/pro-status.test.ts new file mode 100644 index 0000000..3882f12 --- /dev/null +++ b/packages/shared/src/billing/pro-status.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeTrialEndsAt, + deriveProStatus, + isProActive, + TRIAL_DAYS, + type SubscriptionSnapshot, +} from './pro-status.js'; + +const NOW = new Date('2026-05-07T12:00:00Z'); + +function snap(partial: Partial): SubscriptionSnapshot { + return { + status: 'TRIALING', + trialEndsAt: new Date('2026-06-01T00:00:00Z'), + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + ...partial, + }; +} + +describe('isProActive', () => { + it('false se snap è null (nessuna subscription)', () => { + expect(isProActive(null, NOW)).toBe(false); + }); + + it('TRIALING valido finché trialEndsAt è nel futuro', () => { + expect(isProActive(snap({ status: 'TRIALING' }), NOW)).toBe(true); + }); + + it('TRIALING con trialEndsAt passato → false (cron in ritardo)', () => { + expect( + isProActive(snap({ status: 'TRIALING', trialEndsAt: new Date('2026-05-01') }), NOW), + ).toBe(false); + }); + + it('ACTIVE sempre true', () => { + expect(isProActive(snap({ status: 'ACTIVE' }), NOW)).toBe(true); + }); + + it('PAST_DUE = true (manteniamo accesso durante dunning Stripe)', () => { + expect(isProActive(snap({ status: 'PAST_DUE' }), NOW)).toBe(true); + }); + + it('CANCEL_AT_PERIOD_END = true finché currentPeriodEnd è nel futuro', () => { + expect( + isProActive( + snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: new Date('2026-06-01') }), + NOW, + ), + ).toBe(true); + }); + + it('CANCEL_AT_PERIOD_END con periodo già scaduto → false', () => { + expect( + isProActive( + snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: new Date('2026-05-01') }), + NOW, + ), + ).toBe(false); + }); + + it('CANCELED, EXPIRED → false', () => { + expect(isProActive(snap({ status: 'CANCELED' }), NOW)).toBe(false); + expect(isProActive(snap({ status: 'EXPIRED' }), NOW)).toBe(false); + }); +}); + +describe('deriveProStatus', () => { + it('no subscription → kind no_subscription, isPro false', () => { + expect(deriveProStatus(null, NOW).kind).toBe('no_subscription'); + }); + + it('trial in corso → trialDaysRemaining = ceil giorni mancanti', () => { + const s = deriveProStatus(snap({ trialEndsAt: new Date('2026-05-10T12:00:00Z') }), NOW); + expect(s.kind).toBe('trial'); + expect(s.isPro).toBe(true); + expect(s.trialDaysRemaining).toBe(3); + }); + + it('trial scaduto ma DB ancora TRIALING → kind trial_expired', () => { + const s = deriveProStatus( + snap({ status: 'TRIALING', trialEndsAt: new Date('2026-05-01') }), + NOW, + ); + expect(s.kind).toBe('trial_expired'); + expect(s.isPro).toBe(false); + expect(s.trialDaysRemaining).toBe(0); + }); + + it('cancel_at_period_end → kind canceling con accessEndsAt valorizzato', () => { + const end = new Date('2026-06-01'); + const s = deriveProStatus(snap({ status: 'CANCEL_AT_PERIOD_END', currentPeriodEnd: end }), NOW); + expect(s.kind).toBe('canceling'); + expect(s.accessEndsAt).toEqual(end); + }); +}); + +describe('computeTrialEndsAt', () => { + it('default 30 giorni dalla signup', () => { + const signup = new Date('2026-05-07T00:00:00Z'); + const end = computeTrialEndsAt(signup); + expect(end.toISOString()).toBe('2026-06-06T00:00:00.000Z'); + }); + + it('TRIAL_DAYS = 30', () => { + expect(TRIAL_DAYS).toBe(30); + }); +}); diff --git a/packages/shared/src/billing/pro-status.ts b/packages/shared/src/billing/pro-status.ts new file mode 100644 index 0000000..84f0d8f --- /dev/null +++ b/packages/shared/src/billing/pro-status.ts @@ -0,0 +1,131 @@ +/** + * ADR 0004 — calcolo "isProActive" e derivazione dello stato di abbonamento + * a partire dallo snapshot persistito in DB. Funzione pura e testabile. + * + * NB: lo stato in DB viene aggiornato dal webhook Stripe; questa logica è + * usata sia dal backend (paywall middleware) sia dal frontend (banner / + * pulsanti CTA in /billing). + */ + +export const TRIAL_DAYS = 30; + +export type SubscriptionStatus = + | 'TRIALING' + | 'ACTIVE' + | 'PAST_DUE' + | 'CANCEL_AT_PERIOD_END' + | 'CANCELED' + | 'EXPIRED'; + +export interface SubscriptionSnapshot { + status: SubscriptionStatus; + trialEndsAt: Date; + currentPeriodEnd: Date | null; + cancelAtPeriodEnd: boolean; +} + +/** + * Vero se l'utente ha diritto alle feature Pro in questo momento. + * Gestita anche la transizione "trial scaduto ma non ancora marcato EXPIRED + * dal cron": in lettura calcoliamo live, così non serve un job che gira + * ogni minuto. Il cron resta utile solo per allineare lo stato persistito. + */ +export function isProActive(snap: SubscriptionSnapshot | null, now: Date = new Date()): boolean { + if (!snap) return false; + const t = now.getTime(); + switch (snap.status) { + case 'TRIALING': + return snap.trialEndsAt.getTime() > t; + case 'ACTIVE': + case 'PAST_DUE': + // Durante PAST_DUE Stripe ha la sua dunning sequence (3-7gg). + // Manteniamo l'accesso Pro: l'utente non ha colpa di un retry in corso. + return true; + case 'CANCEL_AT_PERIOD_END': + return snap.currentPeriodEnd != null && snap.currentPeriodEnd.getTime() > t; + case 'CANCELED': + case 'EXPIRED': + return false; + } +} + +export type ProDerivedKind = + | 'trial' + | 'trial_expired' + | 'active' + | 'past_due' + | 'canceling' + | 'canceled' + | 'no_subscription'; + +export interface ProDerivedStatus { + kind: ProDerivedKind; + /** True ↔ isProActive(snap). Comodità per l'UI. */ + isPro: boolean; + /** Giorni rimasti del trial (solo se `kind === 'trial'`), arrotondati per eccesso. */ + trialDaysRemaining: number | null; + /** Quando finisce l'accesso (trial o abbonamento). Null se canceled/no_sub. */ + accessEndsAt: Date | null; +} + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +export function deriveProStatus( + snap: SubscriptionSnapshot | null, + now: Date = new Date(), +): ProDerivedStatus { + if (!snap) { + return { kind: 'no_subscription', isPro: false, trialDaysRemaining: null, accessEndsAt: null }; + } + const isPro = isProActive(snap, now); + const t = now.getTime(); + switch (snap.status) { + case 'TRIALING': { + const remainingMs = snap.trialEndsAt.getTime() - t; + if (remainingMs <= 0) { + // trial scaduto ma DB ancora TRIALING (cron in ritardo): trattiamo come expired + return { + kind: 'trial_expired', + isPro: false, + trialDaysRemaining: 0, + accessEndsAt: snap.trialEndsAt, + }; + } + return { + kind: 'trial', + isPro: true, + trialDaysRemaining: Math.max(1, Math.ceil(remainingMs / MS_PER_DAY)), + accessEndsAt: snap.trialEndsAt, + }; + } + case 'ACTIVE': + return { + kind: 'active', + isPro, + trialDaysRemaining: null, + accessEndsAt: snap.currentPeriodEnd, + }; + case 'PAST_DUE': + return { + kind: 'past_due', + isPro, + trialDaysRemaining: null, + accessEndsAt: snap.currentPeriodEnd, + }; + case 'CANCEL_AT_PERIOD_END': + return { + kind: 'canceling', + isPro, + trialDaysRemaining: null, + accessEndsAt: snap.currentPeriodEnd, + }; + case 'CANCELED': + case 'EXPIRED': + return { kind: 'canceled', isPro: false, trialDaysRemaining: null, accessEndsAt: null }; + } +} + +/** Calcola la data di fine trial a partire dalla data di signup. */ +export function computeTrialEndsAt(signupAt: Date, days: number = TRIAL_DAYS): Date { + return new Date(signupAt.getTime() + days * MS_PER_DAY); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1c49db2..240b4d6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,6 @@ export * from './achievements/definitions.js'; export * from './achievements/evaluator.js'; +export * from './billing/pro-status.js'; export * from './medical/conditions.js'; export * from './planner/adherence.js'; export * from './notifications/schema.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e28dc86d63a16705e884a53838fced4d7ffa5f7d..19995125a57b90f27a5b6bb4acfbc6d12aadfcf1 100644 GIT binary patch delta 687 zcmZ2*SLD+}kqvyD)1}`qmu}AItYc&=E-A_^NS$mbDaK}Gq-UsSIC-J8Ff))f*-%ts z^E0l+5XF=8Im?}5HA9ng^HV${1B}c| zoU*+v+|6A|-P6O%4FcRsQ*(3NqLND8jKj-|3`2^t+_FLwjYG`+Jky<1%|gP;BP^W! zjVkj>wJS~xb^e5iTOOWJom_7ZjFY_dV%8sL`SYXRyI{i)n zrc1%r|3OhJV9g>k-69x^+Q}DeIi|05W0u(-5W>9g5jeiL=bN)|F(MfN_Lb=L!xk*l zkVF%KA;LHPj3rAuk{~!l1g3AcVp)YG`W1#mCkNx^2<|y(!uLhDBSf0H6xz8I7`JmN zFjZaOzWfK1hd+ynp0VC^M;jKy?P?y(TugAj&-7V-%yZye!|A#H%qG)2y_qM&#bTz{ z`JgLNp8mm^g=PAJU}l-=D;!w_rZ4ejUIN#F&Cp|bWlg5{1R`1KJH04~`2t)A*i{HC zf50Vtr(f`9mY&WQiq(B8+ZDo?_dP-mz=G+AELmp5^@9x-oj%8tnSc5YE0%V+gwOPu zwk)EcaNt8oYzKxP8zZWkm8e`%kUx;LfPx(C8I#F;O6=3m*t5)oYk)X@`wa&cr=!z> NvBbB%gN-#y3jjk3m}39{