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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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,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';
|
||||
|
||||
Reference in new issue
Block a user