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

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

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

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

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

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

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

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

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

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

No files matched your search

@@ -0,0 +1,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>): 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);
});
});
+131
View File
@@ -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);
}
+1
View File
@@ -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';