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).
206 lines
5.9 KiB
TypeScript
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
|
|
}
|