diff --git a/apps/api/app/controllers/billing_controller.ts b/apps/api/app/controllers/billing_controller.ts index e981f9b..bf20c92 100644 --- a/apps/api/app/controllers/billing_controller.ts +++ b/apps/api/app/controllers/billing_controller.ts @@ -103,6 +103,44 @@ export default class BillingController { return response.json({ data: { url: session.url } }) } + /** + * POST /api/v1/billing/reactivate — auth. + * + * Annule l'annulation programmée au period_end : set Stripe + * `cancel_at_period_end: false` et persiste côté org. Pas de proration, + * pas de paiement immédiat — la subscription continue son cycle normal. + */ + async reactivate({ auth, response }: HttpContext) { + const organizationId = requireOrgId(auth) + const org = await Organization.findOrFail(organizationId) + + if (!org.stripeSubscriptionId) { + throw new Exception('Aucune souscription active à réactiver', { + status: 400, + code: 'no_active_subscription', + }) + } + if (!org.cancelAtPeriodEnd) { + // Idempotent : déjà actif, on renvoie OK sans toucher Stripe. + return response.json({ data: { ok: true } }) + } + + const stripe = getStripe() + const updated = await stripe.subscriptions.update(org.stripeSubscriptionId, { + cancel_at_period_end: false, + }) + org.cancelAtPeriodEnd = !!updated.cancel_at_period_end // = false normalement + org.subscriptionStatus = updated.status + await org.save() + + logger.info( + { orgId: org.id, subscriptionId: org.stripeSubscriptionId }, + 'Subscription réactivée (cancel_at_period_end=false)' + ) + + return response.json({ data: { ok: true } }) + } + /** * POST /api/v1/billing/portal — auth. * Crée une session Stripe Customer Portal pour gérer abonnement, CB, @@ -259,6 +297,7 @@ export default class BillingController { org.subscriptionStatus = 'canceled' org.billingCycle = null org.currentPeriodEnd = null + org.cancelAtPeriodEnd = false await org.save() logger.info({ orgId: org.id }, 'Org redescendue en plan free (subscription deleted)') } @@ -297,6 +336,9 @@ export default class BillingController { org.currentPeriodEnd = item.current_period_end ? DateTime.fromSeconds(item.current_period_end) : null + // L'user a-t-il programmé une annulation ? (via Customer Portal) + // Reflété en UI pour qu'il sache que son accès s'éteint au period_end. + org.cancelAtPeriodEnd = !!subscription.cancel_at_period_end await org.save() logger.info( diff --git a/apps/api/app/services/billing.ts b/apps/api/app/services/billing.ts index 5c19a07..433bca3 100644 --- a/apps/api/app/services/billing.ts +++ b/apps/api/app/services/billing.ts @@ -144,6 +144,13 @@ export type OrgSubscriptionState = { currentPeriodEnd: string | null /** True si l'org a un Stripe customer ID (= a déjà payé une fois). */ hasStripeCustomer: boolean + /** + * True si l'user a annulé sa souscription côté Stripe et qu'elle s'éteindra + * à `currentPeriodEnd`. Pendant cette fenêtre l'org reste sur son plan + * payant (status `active`), mais l'UI affiche "annulé, accès jusqu'au DD/MM" + * et propose un bouton "Réactiver". + */ + cancelAtPeriodEnd: boolean } export async function getOrgSubscriptionState( @@ -168,5 +175,6 @@ export async function getOrgSubscriptionState( : null, currentPeriodEnd: org.currentPeriodEnd?.toISO() ?? null, hasStripeCustomer: !!org.stripeCustomerId, + cancelAtPeriodEnd: !!org.cancelAtPeriodEnd, } } diff --git a/apps/api/database/migrations/1778166051526_create_add_cancel_at_period_ends_table.ts b/apps/api/database/migrations/1778166051526_create_add_cancel_at_period_ends_table.ts new file mode 100644 index 0000000..c3f8db3 --- /dev/null +++ b/apps/api/database/migrations/1778166051526_create_add_cancel_at_period_ends_table.ts @@ -0,0 +1,24 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * `cancel_at_period_end` : Stripe permet à l'user d'annuler une souscription + * sans coupure immédiate — le sub reste `active` jusqu'à `current_period_end`, + * puis Stripe envoie `customer.subscription.deleted`. Pendant cette fenêtre on + * doit afficher "annulé, accès jusqu'au DD/MM" côté UI au lieu de + * "prochaine facture le DD/MM" — sinon l'user pense qu'il va re-payer. + */ +export default class extends BaseSchema { + protected tableName = 'organizations' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.boolean('cancel_at_period_end').notNullable().defaultTo(false) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('cancel_at_period_end') + }) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index c86d2b1..9ecc9c0 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -218,10 +218,12 @@ export class InvoiceSchema extends BaseModel { } export class OrganizationSchema extends BaseModel { - static $columns = ['billingCycle', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const + static $columns = ['billingCycle', 'cancelAtPeriodEnd', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const $columns = OrganizationSchema.$columns @column() declare billingCycle: string | null + @column() + declare cancelAtPeriodEnd: boolean @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @column.dateTime() diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 580c8cc..b35c9a5 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -185,6 +185,9 @@ router .post('checkout', [controllers.Billing, 'checkout']) .as('checkout') router.post('portal', [controllers.Billing, 'portal']).as('portal') + router + .post('reactivate', [controllers.Billing, 'reactivate']) + .as('reactivate') }) .prefix('billing') .as('billing') diff --git a/apps/web/src/lib/billing.test.tsx b/apps/web/src/lib/billing.test.tsx index 1064abf..27e99db 100644 --- a/apps/web/src/lib/billing.test.tsx +++ b/apps/web/src/lib/billing.test.tsx @@ -46,6 +46,7 @@ function fakeState(overrides: Partial = {}): SubscriptionStat billingCycle: null, currentPeriodEnd: null, hasStripeCustomer: false, + cancelAtPeriodEnd: false, ...overrides, }; } diff --git a/apps/web/src/lib/billing.ts b/apps/web/src/lib/billing.ts index e1f2207..bbe67a6 100644 --- a/apps/web/src/lib/billing.ts +++ b/apps/web/src/lib/billing.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; /** Identique au type côté API (`OrgSubscriptionState`). */ @@ -21,6 +21,8 @@ export type SubscriptionState = { billingCycle: BillingCycle | null; currentPeriodEnd: string | null; hasStripeCustomer: boolean; + /** L'user a annulé : sub reste active jusqu'à currentPeriodEnd puis Free. */ + cancelAtPeriodEnd: boolean; }; /** Lit l'état de l'abonnement courant. */ @@ -54,6 +56,20 @@ export function useOpenPortal() { }); } +/** + * Réactive une souscription annulée (cancel_at_period_end=true → false). + * Pas de paiement immédiat — le sub continue son cycle normal. + */ +export function useReactivateSubscription() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post<{ ok: true }>("/api/v1/billing/reactivate"), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["billing", "subscription"] }); + }, + }); +} + /** * True si l'org est sur Free, hors grace period, et ≥ limit. Le SPA * l'utilise pour afficher un banner "limite atteinte" et bloquer diff --git a/apps/web/src/routes/_app/parametres_.abonnement.tsx b/apps/web/src/routes/_app/parametres_.abonnement.tsx index 64e5740..a732f01 100644 --- a/apps/web/src/routes/_app/parametres_.abonnement.tsx +++ b/apps/web/src/routes/_app/parametres_.abonnement.tsx @@ -1,10 +1,16 @@ import { useEffect, useState } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { ArrowLeft, ArrowRight, CreditCard } from "lucide-react"; +import { ArrowLeft, ArrowRight, CreditCard, RotateCcw, Clock } from "lucide-react"; import { toast } from "sonner"; import { z } from "zod"; -import { useOpenPortal, useStartCheckout, useSubscription, type BillingCycle } from "@/lib/billing"; +import { + useOpenPortal, + useReactivateSubscription, + useStartCheckout, + useSubscription, + type BillingCycle, +} from "@/lib/billing"; import { cn } from "@/lib/utils"; import { formatDate } from "@/lib/format"; import { Gem } from "@/components/brand/Gem"; @@ -28,6 +34,7 @@ function AbonnementPage() { const { data: sub, isPending } = useSubscription(); const checkout = useStartCheckout(); const portal = useOpenPortal(); + const reactivate = useReactivateSubscription(); const [cycle, setCycle] = useState("monthly"); useEffect(() => { @@ -59,6 +66,13 @@ function AbonnementPage() { }); }; + const onReactivate = () => { + reactivate.mutate(undefined, { + onSuccess: () => toast.success("Souscription réactivée — tout repart comme avant."), + onError: () => toast.error("Impossible de réactiver. Réessaye."), + }); + }; + const currentPlan = sub?.plan; return ( @@ -90,6 +104,8 @@ function AbonnementPage() { state={sub} onOpenPortal={onOpenPortal} isOpeningPortal={portal.isPending} + onReactivate={onReactivate} + isReactivating={reactivate.isPending} /> )} @@ -136,25 +152,44 @@ function CurrentPlanStrip({ state, onOpenPortal, isOpeningPortal, + onReactivate, + isReactivating, }: { state: ReturnType["data"] & {}; onOpenPortal: () => void; isOpeningPortal: boolean; + onReactivate: () => void; + isReactivating: boolean; }) { - const { plan, activeInvoicesCount, caps, inGracePeriod, gracePeriodEndsAt } = state; + const { plan, activeInvoicesCount, caps, inGracePeriod, gracePeriodEndsAt, cancelAtPeriodEnd } = + state; const limit = caps.activeInvoicesLimit; const isLimited = plan === "free" && limit !== null; const limitReached = isLimited && limit !== null && !inGracePeriod && activeInvoicesCount >= limit; + const isCancelling = plan !== "free" && cancelAtPeriodEnd; return (
- + {isCancelling ? ( +