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 (
+
+ {pending ? t('opening') : t('managePortal')}
+
+ );
+ }
+
+ return (
+
+ handleCheckout('MONTH')} disabled={pending} size="lg">
+ {pending ? t('opening') : t('upgradeMonthly')}
+
+ handleCheckout('YEAR')} disabled={pending} size="lg" variant="outline">
+ {pending ? t('opening') : t('upgradeYearly')}
+
+
+ );
+}
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_qMbTz{
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{