rubis/apps/api/app/services/default_plans.ts
ordinarthur 692b514fe9 feat(api): domaine Plan + PlanStep + provisioning des 4 plans pré-fournis
Migrations :
- plans (uuid id, organization_id FK CASCADE, slug nullable, name, description, is_default). Unique (organization_id, slug) — un slug max par org.
- plan_steps (uuid id, plan_id FK CASCADE, order, offset_days, tone ENUM PG natif, subject, body, requires_manual_validation).

Schema rules : override du tone (introspection PG → 'any', on précise l'union).

Modèles Plan (belongsTo Organization, hasMany PlanStep) et PlanStep (belongsTo Plan).

Décision : plans dupliqués par organisation au signup (pas de table globale partagée). Permet l'édition isolée par org sans toucher aux templates des autres tenants. Le service `provisionDefaultPlans(orgId, trx)` est idempotent et appelé depuis NewAccountController dans la transaction de création.

Source de vérité des 4 plans (Standard B2B, Rapide, Patient, Ferme) dans app/services/default_plans.ts — alignée sur apps/web/src/mocks/seed.ts.

Endpoints :
- GET /plans : liste enrichie avec usageCount (à 0 tant qu'Invoice n'est pas câblé).
- GET /plans/:slug : détail (lookup par slug pour URL stable côté SPA).
- PATCH /plans/:slug : édition partielle. Les steps sont remplacés en bloc dans une transaction (pas de diff fin id-par-id, plus simple et prévisible).

POST plan custom = V2 (cf. backend.md §5.5).
2026-05-06 14:25:06 +02:00

206 lines
5.9 KiB
TypeScript

/**
* Source de vérité des 4 plans pré-fournis (cf. CLAUDE.md → Périmètre V1).
* Dupliqués dans chaque organisation à la création (signup) — V1 mono-tenant
* mais l'isolation est totale, on peut éditer le plan d'une org sans toucher
* aux autres.
*
* Les valeurs (cadences, tons, sujets) doivent rester alignées sur le seed
* MSW (apps/web/src/mocks/seed.ts → SEED_PLANS) tant que les deux coexistent.
*/
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
type DefaultStep = {
order: number
offsetDays: number
tone: 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
subject: string
body: string
requiresManualValidation: boolean
}
type DefaultPlan = {
slug: string
name: string
description: string
steps: DefaultStep[]
}
export const DEFAULT_PLANS: DefaultPlan[] = [
{
slug: 'standard-30j',
name: 'Standard B2B',
description:
'Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.',
steps: [
{
order: 0,
offsetDays: 3,
tone: 'amical',
subject: 'Petit rappel — facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nNous espérons que tout va bien. Un petit rappel concernant la facture {{numero}} d'un montant de {{amount}}, échue le {{dueDate}}.\n\nMerci d'avance,\n{{signature}}",
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 10,
tone: 'courtois',
subject: 'Relance — facture {{numero}} en retard',
body:
"Bonjour {{client.name}},\n\nSauf erreur de notre part, la facture {{numero}} d'un montant de {{amount}} reste impayée.\n\nMerci de procéder au règlement dans les meilleurs délais.\n\n{{signature}}",
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 25,
tone: 'ferme',
subject: 'Mise en demeure — facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nMalgré nos relances, la facture {{numero}} d'un montant de {{amount}} reste impayée. Nous vous mettons en demeure de régler sous 8 jours.\n\n{{signature}}",
requiresManualValidation: true,
},
],
},
{
slug: 'rapide-15j',
name: 'Rapide',
description: 'Cadence resserrée pour les factures récurrentes ou les délais courts.',
steps: [
{
order: 0,
offsetDays: 1,
tone: 'amical',
subject: 'Facture {{numero}} échue',
body: 'Bonjour, petit rappel pour la facture {{numero}}.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 7,
tone: 'courtois',
subject: 'Relance facture {{numero}}',
body: 'La facture {{numero}} reste impayée à ce jour. Merci de régulariser.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 15,
tone: 'ferme',
subject: 'Mise en demeure {{numero}}',
body: 'Mise en demeure formelle de payer sous 8 jours.\n\n{{signature}}',
requiresManualValidation: true,
},
],
},
{
slug: 'patient-60j',
name: 'Patient',
description: 'Pour les clients de longue date. On laisse respirer avant de relancer.',
steps: [
{
order: 0,
offsetDays: 15,
tone: 'amical',
subject: 'Facture {{numero}}',
body: 'Bonjour, simple rappel.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 30,
tone: 'courtois',
subject: 'Relance facture {{numero}}',
body: 'Merci de régulariser dans les meilleurs délais.\n\n{{signature}}',
requiresManualValidation: false,
},
],
},
{
slug: 'ferme-7j',
name: 'Ferme',
description: 'Cadence stricte pour les clients à risque ou les retards récurrents.',
steps: [
{
order: 0,
offsetDays: 1,
tone: 'courtois',
subject: 'Facture {{numero}}',
body: 'Premier rappel.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 5,
tone: 'ferme',
subject: 'Relance ferme {{numero}}',
body: 'Le règlement est attendu sous 48h.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 10,
tone: 'mise_en_demeure',
subject: 'Mise en demeure {{numero}}',
body: 'Mise en demeure formelle.\n\n{{signature}}',
requiresManualValidation: true,
},
],
},
]
/**
* Provisionne les 4 plans par défaut pour une organisation fraîchement créée.
* Idempotent : si l'org a déjà un plan avec un slug, on n'écrase pas.
*
* À appeler dans la transaction de signup.
*/
export async function provisionDefaultPlans(
organizationId: string,
trx: TransactionClientContract
): Promise<Plan[]> {
const existing = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.whereIn(
'slug',
DEFAULT_PLANS.map((p) => p.slug)
)
.select('slug')
const existingSlugs = new Set(existing.map((p) => p.slug))
const created: Plan[] = []
for (const tpl of DEFAULT_PLANS) {
if (existingSlugs.has(tpl.slug)) continue
const plan = await Plan.create(
{
organizationId,
slug: tpl.slug,
name: tpl.name,
description: tpl.description,
isDefault: true,
},
{ client: trx }
)
await PlanStep.createMany(
tpl.steps.map((s) => ({
planId: plan.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
created.push(plan)
}
return created
}