rubis/apps/api/app/services/stripe.ts
ordinarthur 1952265217
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m0s
Build & Deploy Landing / build-and-deploy (push) Successful in 31s
Build & Deploy API / build-and-deploy (push) Successful in 1m52s
feat(billing): plans Free/Pro/Business + Stripe Checkout & Customer Portal
Pricing V1 :
  - Free  : 5 factures actives, 1 user, 3 mois de grâce illimité au signup
  - Pro   : 19 €/mois ou 190 €/an, factures illimitées, 1 user
  - Business : 49 €/mois ou 490 €/an, illimité + 5 sièges (V2 multi-users)
              + reply-from-user-email (V2)

Backend :
  - Migration : plan, grace_period_ends_at, stripe_customer_id,
    stripe_subscription_id, subscription_status, billing_cycle,
    current_period_end sur `organizations`. Backfill grace_period auto.
  - `app/services/billing.ts` : PLAN_CAPS, countActiveInvoices,
    canCreateInvoices (enforce post-grace), getOrgSubscriptionState.
  - `app/services/stripe.ts` : client lazy + lookup_keys stables.
  - `app/controllers/billing_controller.ts` :
      • GET  /billing/subscription      → state pour l'UI
      • POST /billing/checkout          → crée une Checkout Session
      • POST /billing/portal            → Customer Portal Session
      • POST /billing/webhook (public)  → handle 4 events Stripe
        (checkout.completed, subscription.updated/deleted, invoice.payment_failed)
  - `commands/stripe_setup.ts` : `node ace stripe:setup` crée Products +
    Prices (idempotent via lookup_key).
  - Enforcement 402 `plan_limit_reached` sur :
      • POST /invoices (saisie manuelle)
      • POST /invoices/import-batch/:id/drafts/:draftId/validate (OCR)

Frontend :
  - `lib/billing.ts` : useSubscription, useStartCheckout, useOpenPortal,
    useIsAtFreeLimit.
  - `routes/_app/parametres_.abonnement.tsx` : page comparaison plans
    avec toggle mensuel/annuel, current plan + portail Stripe, CTA upgrade
    qui redirige vers Checkout hostée.
  - `routes/_app/parametres.tsx` : nouvelle section "Abonnement" qui
    affiche le plan courant + lien vers la page abonnement.
  - `components/billing/PlanLimitBanner.tsx` : banner sur /factures qui
    s'adapte selon période (grâce / approche / atteinte).
  - Toast dédié 402 sur la validation OCR avec action "Passer Pro".

Doc :
  - flow.md : nouvelle section §11 "Pricing & enforcement" qui couvre
    plans, grâce, webhook flow, Customer Portal, env vars.

Setup dev :
  1. STRIPE_SECRET_KEY (sk_test_...) dans apps/api/.env
  2. `stripe listen --forward-to localhost:3333/api/v1/billing/webhook`
     → copier whsec_... → STRIPE_WEBHOOK_SECRET
  3. `node ace stripe:setup` une fois pour créer Products+Prices
  4. Tester via /parametres/abonnement → checkout en mode test Stripe

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:03:28 +02:00

64 lines
1.8 KiB
TypeScript

import Stripe from 'stripe'
import env from '#start/env'
/**
* Singleton client Stripe — lazy init pour ne pas crasher en dev/test
* quand la clé n'est pas définie. Toute fonction qui nécessite Stripe
* appelle `getStripe()` qui throw si la clé manque.
*/
let _stripe: Stripe | null = null
export function getStripe(): Stripe {
if (_stripe) return _stripe
const key = env.get('STRIPE_SECRET_KEY')
if (!key) {
throw new Error(
'STRIPE_SECRET_KEY manquante. Configurer la clé dans .env avant d\'utiliser le billing.'
)
}
_stripe = new Stripe(key, {
apiVersion: '2026-04-22.dahlia',
typescript: true,
appInfo: {
name: 'Rubis Sur l\'Ongle',
version: '1.0.0',
},
})
return _stripe
}
/**
* Lookup keys utilisés pour identifier les Prices Stripe sans hardcoder
* d'IDs en env. Les Prices sont créées par `node ace stripe:setup` avec
* ces lookup_keys, et le code les retrouve via `prices.list({lookup_keys})`.
*/
export const STRIPE_LOOKUP_KEYS = {
pro_monthly: 'rubis_pro_monthly',
pro_yearly: 'rubis_pro_yearly',
business_monthly: 'rubis_business_monthly',
business_yearly: 'rubis_business_yearly',
} as const
export type StripeLookupKey = (typeof STRIPE_LOOKUP_KEYS)[keyof typeof STRIPE_LOOKUP_KEYS]
/**
* Récupère un Price Stripe via son lookup_key. Throw si introuvable
* (signal que `stripe:setup` n'a pas été lancé ou que les lookup_keys
* ont changé).
*/
export async function getPriceByLookup(key: StripeLookupKey): Promise<Stripe.Price> {
const stripe = getStripe()
const result = await stripe.prices.list({
lookup_keys: [key],
limit: 1,
expand: ['data.product'],
})
const price = result.data[0]
if (!price) {
throw new Error(
`Stripe Price introuvable pour lookup_key="${key}". Lancer \`node ace stripe:setup\` ?`
)
}
return price
}