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>
64 lines
1.8 KiB
TypeScript
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
|
|
}
|