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

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

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

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

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

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

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

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

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

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

No files matched your search

+53
View File
@@ -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>{name}</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."
}
}
}