rubis/apps/api/app/services/mail_dispatcher.ts
ordinarthur 94263c6447 feat(api): check-in flow — email à l'user + endpoints publics paid/pending
Le check-in remplace l'intégration banking V1 (cf. CLAUDE.md → Glossaire) :
avant que la 1re relance ne parte, on demande à l'user "as-tu été payé ?"
via email, et il clique sur l'un des 2 liens publics.

Service checkin_token.ts : génération + hash SHA-256. 32 bytes random base64url, plain dans le mail, hash en DB (CheckinTask.token_hash unique).

Service checkin_scheduler.ts :
- scheduleCheckinForInvoice(invoice) : crée 1 CheckinTask à dueDate (now+1min si dueDate dans le passé). Idempotent par invoice — cancel les scheduled précédents avant.
- cancelCheckinForInvoice(invoiceId) : appelé par mark-paid pour stopper.

Job send_checkin_job.ts : worker queue 'checkins', skip si invoice paid/cancelled (no-op), construit l'URL avec le plain token (passé dans le payload du job, pas relu DB), appelle sendCheckinEmail.

mail_dispatcher.ts : sendCheckinEmail() — texte brut, destinataire = user (pas client !), 2 URLs (paid / pending), TTL 24h annoncé.

Controller CheckinController :
- GET /api/v1/checkin/:token/paid : status=answered + answer=paid + mark invoice paid (mêmes effets que POST /invoices/:id/mark-paid : rubis +1, ActivityEvent invoice_paid avec label "via check-in", cancelFutureRelances). Idempotent : 2e click → redirect "already_answered".
- GET /api/v1/checkin/:token/pending : status=answered + answer=still_pending. Les relances suivent leur cours.
- Validation : lookup hash, expiry (sentAt + 24h), redirects propres pour invalid / expired / already_answered.

Routes : nouveau group public `checkin` (PAS de middleware.auth) à côté du group auth, sous /api/v1.

Triggers branchés :
- InvoicesController.store et ImportBatchesController.validateDraft → scheduleCheckinForInvoice après création
- InvoicesController.markPaid → cancelCheckinForInvoice dans la tx

start/queue.ts : registerWorker('checkins', sendCheckinJob).

env : nouveau WEB_URL (URL du SPA pour redirects), default localhost:5173 en dev.

Bruno : nouveau dossier 08-Checkin avec doc complète du flow + 2 requêtes (paid / pending). var d'env `checkinToken` à remplir manuellement après avoir reçu l'email dans Mailpit.
2026-05-06 15:31:40 +02:00

99 lines
3.1 KiB
TypeScript

import mail from '@adonisjs/mail/services/main'
import env from '#start/env'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
import type Invoice from '#models/invoice'
import type Client from '#models/client'
import type PlanStep from '#models/plan_step'
import type User from '#models/user'
type RelancePayload = {
invoice: Invoice
client: Client
step: PlanStep
user: User | null
}
/**
* Envoie un email de relance à un client à partir d'un step.
* Le subject/body du step contiennent des placeholders Mustache-like
* (`{{client.name}}`, `{{numero}}`, `{{amount}}`, `{{dueDate}}`,
* `{{signature}}`) qu'on interpole avant l'envoi.
*
* Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
* `resend` en prod).
*/
export async function sendRelanceEmail({ invoice, client, step, user }: RelancePayload) {
const vars = {
client: { name: client.name, email: client.email },
numero: invoice.numero,
amount: formatAmountFr(invoice.amountTtcCents),
dueDate: formatDateFr(invoice.dueDate.toJSDate()),
issueDate: formatDateFr(invoice.issueDate.toJSDate()),
signature: user?.signature ?? user?.fullName ?? '',
}
const subject = renderTemplate(step.subject, vars)
const body = renderTemplate(step.body, vars)
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
// décidé d'un look graphique pour les relances.
.text(body)
})
}
type CheckinPayload = {
invoice: Invoice
client: Client
user: User
paidUrl: string
pendingUrl: string
}
/**
* Envoie le check-in à l'**utilisateur** (pas au client). Lui demande
* si la facture a été payée, avec 2 liens publics qui modifient l'état
* côté API et redirigent ensuite vers le SPA.
*
* Texte brut V1. Un template HTML viendra quand on aura figé le look
* graphique (cf. ADR-021).
*/
export async function sendCheckinEmail({
invoice,
client,
user,
paidUrl,
pendingUrl,
}: CheckinPayload) {
const subject = `Facture ${invoice.numero} — payée par ${client.name} ?`
const body = `Bonjour ${user.fullName ?? ''},
La facture ${invoice.numero} (${formatAmountFr(invoice.amountTtcCents)}) émise pour ${client.name}
arrive à échéance aujourd'hui (${formatDateFr(invoice.dueDate.toJSDate())}).
Avant que Rubis n'envoie la première relance, dites-nous où vous en êtes :
✓ J'ai été payé(e), pas besoin de relancer :
${paidUrl}
→ Toujours en attente, lance la relance comme prévu :
${pendingUrl}
Ces liens expirent dans 24h.
Merci,
L'équipe Rubis`
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.text(body)
})
}