rubis/apps/api/commands/stripe_setup.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

144 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { getStripe, STRIPE_LOOKUP_KEYS } from '#services/stripe'
/**
* Crée / met à jour les Products + Prices Stripe pour Rubis.
*
* Idempotent : on cherche par `lookup_key` avant de créer. Si déjà
* existant, on log "OK" et on passe. Pas d'écrasement (les prices Stripe
* sont immuables — on ne peut pas modifier le montant d'un Price existant,
* il faut en créer un nouveau).
*
* Lance ce command UNE FOIS au setup initial (test ou prod), puis quand
* tu veux ajouter de nouveaux Prices.
*
* node ace stripe:setup
*
* Pour rebattre les cartes : aller manuellement archive les Prices/Products
* dans le dashboard Stripe puis relancer.
*/
export default class StripeSetup extends BaseCommand {
static commandName = 'stripe:setup'
static description = 'Crée les Products et Prices Stripe (Pro / Business, monthly + yearly)'
static options: CommandOptions = {
startApp: true,
}
async run() {
// Lazy validation : provoque l'exception immédiate si la clé manque.
getStripe()
this.logger.info('Stripe setup — création des Products + Prices')
// ---------------- PRO ----------------
const proProduct = await this.ensureProduct({
name: 'Rubis Pro',
description:
"Factures illimitées, OCR illimité, automatisation complète des relances. Pour les TPE actives qui ne veulent plus jamais y penser.",
metadata: { plan_key: 'pro' },
})
await this.ensurePrice({
productId: proProduct.id,
lookupKey: STRIPE_LOOKUP_KEYS.pro_monthly,
unitAmount: 1900, // 19,00 €
interval: 'month',
nickname: 'Pro mensuel',
})
await this.ensurePrice({
productId: proProduct.id,
lookupKey: STRIPE_LOOKUP_KEYS.pro_yearly,
// 19 € × 12 = 228 €. -17% (= ~190 €) pour récompenser l'engagement annuel.
unitAmount: 19_000,
interval: 'year',
nickname: 'Pro annuel (-17%)',
})
// ---------------- BUSINESS ----------------
const bizProduct = await this.ensureProduct({
name: 'Rubis Business',
description:
"Factures illimitées + 5 sièges utilisateurs + réponses depuis l'email de l'utilisateur. Pour les PME qui ont une vraie équipe.",
metadata: { plan_key: 'business' },
})
await this.ensurePrice({
productId: bizProduct.id,
lookupKey: STRIPE_LOOKUP_KEYS.business_monthly,
unitAmount: 4900, // 49,00 €
interval: 'month',
nickname: 'Business mensuel',
})
await this.ensurePrice({
productId: bizProduct.id,
lookupKey: STRIPE_LOOKUP_KEYS.business_yearly,
// 49 € × 12 = 588 €. -17% (= ~490 €) annuel.
unitAmount: 49_000,
interval: 'year',
nickname: 'Business annuel (-17%)',
})
this.logger.success('Stripe setup terminé.')
}
private async ensureProduct(input: {
name: string
description: string
metadata: Record<string, string>
}) {
const stripe = getStripe()
// Lookup via metadata (Stripe ne permet pas de lookup_key sur Product,
// seulement sur Price). On utilise donc list + filter.
const existing = await stripe.products.list({ active: true, limit: 100 })
const found = existing.data.find(
(p) => p.metadata?.['plan_key'] === input.metadata['plan_key']
)
if (found) {
this.logger.info(` Product ${input.name} : déjà existant (${found.id})`)
return found
}
const created = await stripe.products.create({
name: input.name,
description: input.description,
metadata: input.metadata,
})
this.logger.success(` Product ${input.name} créé : ${created.id}`)
return created
}
private async ensurePrice(input: {
productId: string
lookupKey: string
unitAmount: number
interval: 'month' | 'year'
nickname: string
}) {
const stripe = getStripe()
const existing = await stripe.prices.list({
lookup_keys: [input.lookupKey],
limit: 1,
})
if (existing.data[0]) {
this.logger.info(
` Price ${input.nickname} : déjà existant (${existing.data[0].id})`
)
return existing.data[0]
}
const created = await stripe.prices.create({
product: input.productId,
unit_amount: input.unitAmount,
currency: 'eur',
recurring: { interval: input.interval },
lookup_key: input.lookupKey,
nickname: input.nickname,
})
this.logger.success(
` Price ${input.nickname} créé : ${created.id} (lookup=${input.lookupKey})`
)
return created
}
}