Quand l'utilisateur confirme « Oui, payé » via check-in (lien email ou modale in-app) ou marque une facture encaissée manuellement, on envoie automatiquement un email de remerciement chaleureux au client final. Subject + body éditables par plan (mêmes variables que les relances), avec fallback hardcodé si vide. Gardé par la transition `* → paid` pour idempotence ; envoi async via BullMQ avec retry exponentiel ; capture en mode démo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
233 lines
7.2 KiB
TypeScript
233 lines
7.2 KiB
TypeScript
import Plan from '#models/plan'
|
|
import PlanStep from '#models/plan_step'
|
|
import PlanTransformer from '#transformers/plan_transformer'
|
|
import { createPlanValidator, updatePlanValidator } from '#validators/plan'
|
|
import type { HttpContext } from '@adonisjs/core/http'
|
|
import { Exception } from '@adonisjs/core/exceptions'
|
|
import db from '@adonisjs/lucid/services/db'
|
|
|
|
/**
|
|
* Slug à partir d'un nom de plan : minuscules, ASCII safe, tirets.
|
|
* On garantit l'unicité par org en suffixant un compteur si collision.
|
|
*/
|
|
function slugify(input: string): string {
|
|
return input
|
|
.normalize('NFD')
|
|
.replace(/[̀-ͯ]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 60) || 'plan'
|
|
}
|
|
|
|
// Slugs réservés côté front (routes statiques type /plans/nouveau).
|
|
// Si l'utilisateur nomme son plan "nouveau", on suffixe d'office.
|
|
const RESERVED_SLUGS = new Set(['nouveau', 'new', 'create'])
|
|
|
|
async function nextAvailableSlug(organizationId: string, base: string): Promise<string> {
|
|
const start = RESERVED_SLUGS.has(base) ? `${base}-1` : base
|
|
const existing = await Plan.query()
|
|
.where('organization_id', organizationId)
|
|
.whereILike('slug', `${base}%`)
|
|
.select('slug')
|
|
const taken = new Set(existing.map((p) => p.slug))
|
|
if (!taken.has(start) && !RESERVED_SLUGS.has(start)) return start
|
|
for (let i = 2; i < 100; i++) {
|
|
const candidate = `${base}-${i}`
|
|
if (!taken.has(candidate) && !RESERVED_SLUGS.has(candidate)) return candidate
|
|
}
|
|
return `${base}-${Date.now()}`
|
|
}
|
|
|
|
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
|
|
|
|
function requireOrgId(auth: HttpContext['auth']): string {
|
|
const user = auth.getUserOrFail()
|
|
if (!user.organizationId) {
|
|
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
|
|
}
|
|
return user.organizationId
|
|
}
|
|
|
|
function serializePlan(p: Plan) {
|
|
return new PlanTransformer(p).toObject()
|
|
}
|
|
|
|
/**
|
|
* Compte combien de factures actives (non payées, non annulées) référencent
|
|
* chaque plan d'une org. Utilisé pour enrichir la liste avec un badge "X
|
|
* factures utilisent ce plan" — utile avant édition pour signaler l'impact.
|
|
*/
|
|
async function bulkComputePlanUsage(
|
|
organizationId: string,
|
|
planIds: string[]
|
|
): Promise<Map<string, number>> {
|
|
const map = new Map<string, number>()
|
|
for (const id of planIds) map.set(id, 0)
|
|
|
|
if (planIds.length === 0) return map
|
|
|
|
const rows = await db
|
|
.from('invoices')
|
|
.where('organization_id', organizationId)
|
|
.whereIn('plan_id', planIds)
|
|
.whereRaw(`status::text in ${ACTIVE_INVOICE_STATUSES}`)
|
|
.select('plan_id')
|
|
.count('* as count')
|
|
.groupBy('plan_id')
|
|
|
|
for (const r of rows) {
|
|
map.set(r.plan_id, Number(r.count))
|
|
}
|
|
return map
|
|
}
|
|
|
|
export default class PlansController {
|
|
/**
|
|
* GET /plans — liste enrichie avec compteurs d'usage.
|
|
*/
|
|
async index({ auth, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
|
|
const plans = await Plan.query()
|
|
.where('organization_id', organizationId)
|
|
.preload('steps')
|
|
.orderBy('is_default', 'desc')
|
|
.orderBy('name', 'asc')
|
|
|
|
const usage = await bulkComputePlanUsage(
|
|
organizationId,
|
|
plans.map((p) => p.id)
|
|
)
|
|
|
|
const data = plans.map((p) => ({
|
|
...serializePlan(p),
|
|
usageCount: usage.get(p.id) ?? 0,
|
|
}))
|
|
|
|
return response.json({ data })
|
|
}
|
|
|
|
/**
|
|
* GET /plans/:slug — détail.
|
|
* Le SPA lookup par slug pour les plans pré-fournis (URL stable et
|
|
* lisible : /parametres/plans/standard-30j).
|
|
*/
|
|
async show({ auth, params, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
|
|
const plan = await Plan.query()
|
|
.where('organization_id', organizationId)
|
|
.where('slug', params.slug)
|
|
.preload('steps')
|
|
.first()
|
|
|
|
if (!plan) {
|
|
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
|
|
}
|
|
|
|
const usage = await bulkComputePlanUsage(organizationId, [plan.id])
|
|
return response.json({
|
|
data: { ...serializePlan(plan), usageCount: usage.get(plan.id) ?? 0 },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* PATCH /plans/:slug — édite nom, description et/ou recompose les étapes.
|
|
*
|
|
* Recomposition des steps : on ne fait pas de diff fin (id par id), on
|
|
* remplace tout le set en transaction. Plus simple, plus prévisible, et
|
|
* idiomatique côté UX (l'utilisateur a édité son plan dans son ensemble).
|
|
*/
|
|
async update({ auth, params, request, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
const payload = await request.validateUsing(updatePlanValidator)
|
|
|
|
const plan = await Plan.query()
|
|
.where('organization_id', organizationId)
|
|
.where('slug', params.slug)
|
|
.first()
|
|
|
|
if (!plan) {
|
|
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
|
|
}
|
|
|
|
await db.transaction(async (trx) => {
|
|
plan.useTransaction(trx)
|
|
if (payload.name !== undefined) plan.name = payload.name
|
|
if (payload.description !== undefined) plan.description = payload.description
|
|
if (payload.thanksSubject !== undefined) plan.thanksSubject = payload.thanksSubject
|
|
if (payload.thanksBody !== undefined) plan.thanksBody = payload.thanksBody
|
|
await plan.save()
|
|
|
|
if (payload.steps !== undefined) {
|
|
// Remplace tout le set
|
|
await PlanStep.query({ client: trx }).where('plan_id', plan.id).delete()
|
|
await PlanStep.createMany(
|
|
payload.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 }
|
|
)
|
|
}
|
|
})
|
|
|
|
await plan.load('steps')
|
|
return response.json({ data: serializePlan(plan) })
|
|
}
|
|
|
|
/**
|
|
* POST /plans — création d'un plan custom.
|
|
*
|
|
* Slug auto-généré depuis `name`, suffixé en cas de collision dans l'org.
|
|
* Le plan custom n'est pas marqué `isDefault` — il peut être supprimé
|
|
* (V2) sans toucher à la bibliothèque.
|
|
*/
|
|
async store({ auth, request, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
const payload = await request.validateUsing(createPlanValidator)
|
|
|
|
const baseSlug = slugify(payload.name)
|
|
const slug = await nextAvailableSlug(organizationId, baseSlug)
|
|
|
|
const plan = await db.transaction(async (trx) => {
|
|
const created = await Plan.create(
|
|
{
|
|
organizationId,
|
|
slug,
|
|
name: payload.name,
|
|
description: payload.description ?? '',
|
|
isDefault: false,
|
|
thanksSubject: payload.thanksSubject ?? null,
|
|
thanksBody: payload.thanksBody ?? null,
|
|
},
|
|
{ client: trx }
|
|
)
|
|
|
|
await PlanStep.createMany(
|
|
payload.steps.map((s) => ({
|
|
planId: created.id,
|
|
order: s.order,
|
|
offsetDays: s.offsetDays,
|
|
tone: s.tone,
|
|
subject: s.subject,
|
|
body: s.body,
|
|
requiresManualValidation: s.requiresManualValidation,
|
|
})),
|
|
{ client: trx }
|
|
)
|
|
|
|
return created
|
|
})
|
|
|
|
await plan.load('steps')
|
|
return response.status(201).json({ data: serializePlan(plan) })
|
|
}
|
|
}
|