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>
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
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
|
||
}
|
||
}
|