From 94263c644747282c35d59575a0e6b6e1fa236c59 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 15:31:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20check-in=20flow=20=E2=80=94=20emai?= =?UTF-8?q?l=20=C3=A0=20l'user=20+=20endpoints=20publics=20paid/pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/.env.example | 5 + .../api/app/controllers/checkin_controller.ts | 138 ++++++++++++++++++ .../controllers/import_batches_controller.ts | 2 + .../app/controllers/invoices_controller.ts | 18 ++- apps/api/app/jobs/send_checkin_job.ts | 69 +++++++++ apps/api/app/services/checkin_scheduler.ts | 96 ++++++++++++ apps/api/app/services/checkin_token.ts | 16 ++ apps/api/app/services/mail_dispatcher.ts | 51 +++++++ apps/api/start/env.ts | 3 + apps/api/start/queue.ts | 8 +- apps/api/start/routes.ts | 17 +++ bruno/08-Checkin/01 Respond paid.bru | 47 ++++++ bruno/08-Checkin/02 Respond pending.bru | 38 +++++ bruno/08-Checkin/folder.bru | 40 +++++ bruno/environments/local.bru | 1 + 15 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 apps/api/app/controllers/checkin_controller.ts create mode 100644 apps/api/app/jobs/send_checkin_job.ts create mode 100644 apps/api/app/services/checkin_scheduler.ts create mode 100644 apps/api/app/services/checkin_token.ts create mode 100644 bruno/08-Checkin/01 Respond paid.bru create mode 100644 bruno/08-Checkin/02 Respond pending.bru create mode 100644 bruno/08-Checkin/folder.bru diff --git a/apps/api/.env.example b/apps/api/.env.example index 4097b4c..7bd15db 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -61,6 +61,11 @@ RESEND_API_KEY= OCR_PROVIDER=mock MISTRAL_API_KEY= +#-------------------------------------------------------------------- +# Web (URL du SPA, utilisée pour les redirects post-checkin) +#-------------------------------------------------------------------- +WEB_URL=http://localhost:5173 + #-------------------------------------------------------------------- # Auth (refresh tokens) #-------------------------------------------------------------------- diff --git a/apps/api/app/controllers/checkin_controller.ts b/apps/api/app/controllers/checkin_controller.ts new file mode 100644 index 0000000..8575345 --- /dev/null +++ b/apps/api/app/controllers/checkin_controller.ts @@ -0,0 +1,138 @@ +import CheckinTask from '#models/checkin_task' +import Invoice from '#models/invoice' +import { hashCheckinToken } from '#services/checkin_token' +import { recordActivity } from '#services/activity_recorder' +import { cancelFutureRelances } from '#services/relance_scheduler' +import db from '@adonisjs/lucid/services/db' +import env from '#start/env' +import { DateTime } from 'luxon' +import type { HttpContext } from '@adonisjs/core/http' + +const CHECKIN_TTL_HOURS = 24 + +/** + * Construit l'URL de redirect SPA selon le résultat. Le SPA lit ces + * query params pour afficher un toast et router l'utilisateur. + */ +function spaRedirectUrl( + result: 'paid' | 'pending' | 'expired' | 'invalid' | 'already_answered', + invoiceNumero?: string +): string { + const base = env.get('WEB_URL', 'http://localhost:5173') + const params = new URLSearchParams({ checkin: result }) + if (invoiceNumero) params.set('invoice', invoiceNumero) + return `${base}/?${params.toString()}` +} + +type ResolvedTask = + | { task: CheckinTask; invoice: Invoice } + | { redirect: string } + +/** + * Lookup + validation commune aux deux endpoints (paid / pending). + * Retourne soit la task validée soit une URL de redirect d'erreur. + */ +async function resolveCheckin(token: string): Promise { + const hashed = hashCheckinToken(token) + const task = await CheckinTask.query().where('token_hash', hashed).first() + if (!task) { + return { redirect: spaRedirectUrl('invalid') } + } + + if (task.status === 'answered') { + const inv = await Invoice.find(task.invoiceId) + return { redirect: spaRedirectUrl('already_answered', inv?.numero) } + } + + // Expiration : 24h après l'envoi (sentAt). Tant qu'elle n'a pas été + // envoyée, le link n'est pas censé exister côté user — sécurité belt. + if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < DateTime.now()) { + task.status = 'expired' + await task.save() + return { redirect: spaRedirectUrl('expired') } + } + + const invoice = await Invoice.query() + .where('id', task.invoiceId) + .preload('client') + .first() + if (!invoice) { + return { redirect: spaRedirectUrl('invalid') } + } + + return { task, invoice } +} + +export default class CheckinController { + /** + * GET /api/v1/checkin/:token/paid + * + * L'utilisateur clique "j'ai été payé". On marque la facture payée + + * cancel les relances futures + bonus rubis (idempotent avec mark-paid). + * Redirect SPA avec `?checkin=paid&invoice=`. + * + * Public : pas d'auth Bearer, c'est un lien dans un email. + */ + async respondPaid({ params, response }: HttpContext) { + const result = await resolveCheckin(params.token) + if ('redirect' in result) { + return response.redirect(result.redirect) + } + const { task, invoice } = result + + await db.transaction(async (trx) => { + task.useTransaction(trx) + task.status = 'answered' + task.answer = 'paid' + task.answeredAt = DateTime.now() + await task.save() + + // Mark paid (mêmes effets que POST /invoices/:id/mark-paid). + if (invoice.status !== 'paid') { + invoice.useTransaction(trx) + invoice.status = 'paid' + invoice.paidAt = DateTime.now() + invoice.rubisEarned = invoice.rubisEarned + 1 + await invoice.save() + + await trx + .from('organizations') + .where('id', invoice.organizationId) + .increment('rubis_count', 1) + + await recordActivity({ + organizationId: invoice.organizationId, + kind: 'invoice_paid', + label: `Facture ${invoice.numero} marquée encaissée via check-in`, + meta: { invoiceId: invoice.id, clientId: invoice.clientId }, + trx, + }) + + await cancelFutureRelances(invoice.id, trx) + } + }) + + return response.redirect(spaRedirectUrl('paid', invoice.numero)) + } + + /** + * GET /api/v1/checkin/:token/pending + * + * L'utilisateur clique "toujours en attente". On marque la task + * answered, les relances suivent leur cours. + */ + async respondPending({ params, response }: HttpContext) { + const result = await resolveCheckin(params.token) + if ('redirect' in result) { + return response.redirect(result.redirect) + } + const { task, invoice } = result + + task.status = 'answered' + task.answer = 'still_pending' + task.answeredAt = DateTime.now() + await task.save() + + return response.redirect(spaRedirectUrl('pending', invoice.numero)) + } +} diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts index 7be813d..43abcaf 100644 --- a/apps/api/app/controllers/import_batches_controller.ts +++ b/apps/api/app/controllers/import_batches_controller.ts @@ -17,6 +17,7 @@ import { } from '#services/import_batch' import { recordActivity } from '#services/activity_recorder' import { scheduleRelancesForInvoice } from '#services/relance_scheduler' +import { scheduleCheckinForInvoice } from '#services/checkin_scheduler' import drive from '@adonisjs/drive/services/main' import { createReadStream } from 'node:fs' import { randomUUID } from 'node:crypto' @@ -225,6 +226,7 @@ export default class ImportBatchesController { if (invoice.planId) { await scheduleRelancesForInvoice(invoice) } + await scheduleCheckinForInvoice(invoice) return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() }) } diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index 8267c0b..84326a8 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -15,6 +15,10 @@ import { scheduleRelancesForInvoice, cancelFutureRelances, } from '#services/relance_scheduler' +import { + scheduleCheckinForInvoice, + cancelCheckinForInvoice, +} from '#services/checkin_scheduler' const PAGE_SIZE = 50 @@ -300,12 +304,13 @@ export default class InvoicesController { await invoice.load('client') await invoice.load('plan') - // Programme les relances BullMQ si la facture a un plan. Hors tx : - // les jobs sont posés dans Redis, on n'a pas besoin de cohérence DB - // (et BullMQ.add() retourne avant d'écrire à Redis sur certains modes). + // Programme les relances BullMQ si la facture a un plan + le check-in + // (envoyé pile à dueDate). Hors tx : les jobs sont posés dans Redis, + // on n'a pas besoin de cohérence DB. if (invoice.planId) { await scheduleRelancesForInvoice(invoice) } + await scheduleCheckinForInvoice(invoice) return response.status(201).json({ data: serializeInvoice(invoice) }) } @@ -355,10 +360,11 @@ export default class InvoicesController { trx, }) - // Annule toutes les relances futures programmées pour cette facture - // (idempotent, BullMQ.remove peut échouer silencieusement si le - // job a déjà été consommé). + // Annule toutes les relances + le check-in programmés pour cette + // facture (idempotent, BullMQ.remove peut échouer silencieusement + // si le job a déjà été consommé). await cancelFutureRelances(invoice.id, trx) + await cancelCheckinForInvoice(invoice.id, trx) }) return response.json({ data: serializeInvoice(invoice) }) diff --git a/apps/api/app/jobs/send_checkin_job.ts b/apps/api/app/jobs/send_checkin_job.ts new file mode 100644 index 0000000..91b2f72 --- /dev/null +++ b/apps/api/app/jobs/send_checkin_job.ts @@ -0,0 +1,69 @@ +import CheckinTask from '#models/checkin_task' +import Invoice from '#models/invoice' +import User from '#models/user' +import { sendCheckinEmail } from '#services/mail_dispatcher' +import env from '#start/env' +import { DateTime } from 'luxon' +import logger from '@adonisjs/core/services/logger' + +/** + * Worker BullMQ pour la queue `checkins`. + * + * Idempotent : si la task n'est plus `scheduled` (déjà envoyée ou + * expirée parce que la facture a été marquée payée entre-temps), + * no-op. + * + * Le `plain` token est passé dans le payload du job (pas relu depuis + * la DB où on n'a que le hash), pour pouvoir construire les URLs. + */ +export async function sendCheckinJob(jobData: { taskId: string; plain: string }) { + const task = await CheckinTask.find(jobData.taskId) + if (!task) { + logger.warn({ taskId: jobData.taskId }, 'checkin task not found, skipping') + return + } + if (task.status !== 'scheduled') { + return + } + + const invoice = await Invoice.query() + .where('id', task.invoiceId) + .preload('client') + .first() + if (!invoice) { + task.status = 'expired' + await task.save() + return + } + + // Si la facture a été payée/annulée entre la programmation et l'exécution, + // on n'envoie pas le check-in (l'utilisateur sait déjà). + if (invoice.status === 'paid' || invoice.status === 'cancelled') { + task.status = 'expired' + await task.save() + return + } + + const user = await User.query().where('organization_id', invoice.organizationId).first() + if (!user) { + task.status = 'expired' + await task.save() + return + } + + const apiUrl = env.get('APP_URL', 'http://localhost:3333') + const paidUrl = `${apiUrl}/api/v1/checkin/${jobData.plain}/paid` + const pendingUrl = `${apiUrl}/api/v1/checkin/${jobData.plain}/pending` + + await sendCheckinEmail({ + invoice, + client: invoice.client, + user, + paidUrl, + pendingUrl, + }) + + task.status = 'sent' + task.sentAt = DateTime.now() + await task.save() +} diff --git a/apps/api/app/services/checkin_scheduler.ts b/apps/api/app/services/checkin_scheduler.ts new file mode 100644 index 0000000..d29694f --- /dev/null +++ b/apps/api/app/services/checkin_scheduler.ts @@ -0,0 +1,96 @@ +import { DateTime } from 'luxon' +import CheckinTask from '#models/checkin_task' +import Invoice from '#models/invoice' +import { getQueue } from '#services/queue' +import { generateCheckinToken } from '#services/checkin_token' +import type { TransactionClientContract } from '@adonisjs/lucid/types/database' + +const CHECKIN_QUEUE = 'checkins' + +/** + * Programme un check-in pour une facture. + * + * V1 : 1 check-in par facture, envoyé à `dueDate` (pile à l'échéance). + * Si dueDate est dans le passé → envoie immédiat (à `now + 1min`), + * pour que les factures importées en retard reçoivent quand même un + * check-in. + * + * Le token est généré ici (plain) — on retourne le plain pour permettre + * au caller de le passer dans des emails de test si besoin, mais en + * pratique seul le hash est stocké et lu via SendCheckinJob. + * + * Idempotent par invoice : si une CheckinTask `scheduled` existe déjà, + * on la cancelle d'abord puis on en crée une nouvelle (cas re-scheduling + * après changement de dueDate). + */ +export async function scheduleCheckinForInvoice( + invoice: Invoice, + trx?: TransactionClientContract +): Promise<{ task: CheckinTask; plain: string } | null> { + // Cancel l'éventuelle CheckinTask scheduled précédente. + const existing = await CheckinTask.query(trx ? { client: trx } : undefined) + .where('invoice_id', invoice.id) + .where('status', 'scheduled') + const queue = getQueue(CHECKIN_QUEUE) + for (const t of existing) { + await queue.remove(`checkin:${t.id}`).catch(() => {}) + t.useTransaction(trx ?? (null as never)) + t.status = 'expired' + await t.save() + } + + const now = DateTime.now() + const sendAtRaw = invoice.dueDate + const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw + + const { plain, hashed } = generateCheckinToken() + + const task = await CheckinTask.create( + { + organizationId: invoice.organizationId, + invoiceId: invoice.id, + sendAt, + tokenHash: hashed, + status: 'scheduled', + sentAt: null, + answeredAt: null, + answer: null, + }, + trx ? { client: trx } : undefined + ) + + const delay = Math.max(0, sendAt.toMillis() - now.toMillis()) + await queue.add( + 'send-checkin', + { taskId: task.id, plain }, + { + delay, + jobId: `checkin:${task.id}`, + attempts: 3, + backoff: { type: 'exponential', delay: 30_000 }, + } + ) + + return { task, plain } +} + +/** + * Annule le check-in scheduled d'une facture (appelé par mark-paid). + */ +export async function cancelCheckinForInvoice( + invoiceId: string, + trx?: TransactionClientContract +): Promise { + const tasks = await CheckinTask.query(trx ? { client: trx } : undefined) + .where('invoice_id', invoiceId) + .where('status', 'scheduled') + if (tasks.length === 0) return + + const queue = getQueue(CHECKIN_QUEUE) + for (const t of tasks) { + await queue.remove(`checkin:${t.id}`).catch(() => {}) + t.useTransaction(trx ?? (null as never)) + t.status = 'expired' + await t.save() + } +} diff --git a/apps/api/app/services/checkin_token.ts b/apps/api/app/services/checkin_token.ts new file mode 100644 index 0000000..041eac9 --- /dev/null +++ b/apps/api/app/services/checkin_token.ts @@ -0,0 +1,16 @@ +import crypto from 'node:crypto' + +/** + * Tokens check-in : 32 bytes random → base64url. On stocke le hash + * SHA-256 en DB (CheckinTask.token_hash). Pas de signature HMAC : le + * token est purement opaque, sa "signature" c'est sa présence en DB. + */ +export function generateCheckinToken(): { plain: string; hashed: string } { + const plain = crypto.randomBytes(32).toString('base64url') + const hashed = crypto.createHash('sha256').update(plain).digest('hex') + return { plain, hashed } +} + +export function hashCheckinToken(plain: string): string { + return crypto.createHash('sha256').update(plain).digest('hex') +} diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts index 1ec4ab3..9e38f2b 100644 --- a/apps/api/app/services/mail_dispatcher.ts +++ b/apps/api/app/services/mail_dispatcher.ts @@ -45,3 +45,54 @@ export async function sendRelanceEmail({ invoice, client, step, user }: RelanceP .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) + }) +} diff --git a/apps/api/start/env.ts b/apps/api/start/env.ts index 608b77e..73b58c0 100644 --- a/apps/api/start/env.ts +++ b/apps/api/start/env.ts @@ -59,6 +59,9 @@ export default await Env.create(new URL('../', import.meta.url), { OCR_PROVIDER: Env.schema.enum.optional(['mock', 'mistral'] as const), MISTRAL_API_KEY: Env.schema.string.optional(), + // Web (URL du SPA pour redirects post-checkin) + WEB_URL: Env.schema.string.optional({ format: 'url', tld: false }), + // Auth ACCESS_TOKEN_TTL_MINUTES: Env.schema.number.optional(), REFRESH_TOKEN_TTL_DAYS: Env.schema.number.optional(), diff --git a/apps/api/start/queue.ts b/apps/api/start/queue.ts index 6274f3d..a7d1803 100644 --- a/apps/api/start/queue.ts +++ b/apps/api/start/queue.ts @@ -13,16 +13,22 @@ import app from '@adonisjs/core/services/app' import logger from '@adonisjs/core/services/logger' import { registerWorker, shutdownQueue } from '#services/queue' import { sendRelanceJob } from '#jobs/send_relance_job' +import { sendCheckinJob } from '#jobs/send_checkin_job' // On enregistre les workers seulement quand l'app sert HTTP — pas en // mode test (pour ne pas connecter Redis pendant les tests Japa) ni en // REPL (pour ne pas déclencher d'exécution latérale). if (app.getEnvironment() === 'web') { - logger.info('booting BullMQ workers (relances)') + logger.info('booting BullMQ workers (relances, checkins)') + registerWorker<{ taskId: string }>('relances', async (job) => { await sendRelanceJob(job.data) }) + registerWorker<{ taskId: string; plain: string }>('checkins', async (job) => { + await sendCheckinJob(job.data) + }) + app.terminating(async () => { logger.info('shutting down BullMQ workers') await shutdownQueue() diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 6ecf6bb..178a419 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -32,6 +32,23 @@ router .prefix('auth') .as('auth') + /** + * Check-in — public (pas d'auth Bearer). Token signé en URL, + * lookup hash en DB. Redirige vers le SPA avec ?checkin=... pour + * que l'UI affiche un toast. + */ + router + .group(() => { + router + .get(':token/paid', [controllers.Checkin, 'respondPaid']) + .as('paid') + router + .get(':token/pending', [controllers.Checkin, 'respondPending']) + .as('pending') + }) + .prefix('checkin') + .as('checkin') + /** * Compte courant — auth requise. */ diff --git a/bruno/08-Checkin/01 Respond paid.bru b/bruno/08-Checkin/01 Respond paid.bru new file mode 100644 index 0000000..a160d6f --- /dev/null +++ b/bruno/08-Checkin/01 Respond paid.bru @@ -0,0 +1,47 @@ +meta { + name: 01 Respond paid + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/checkin/{{checkinToken}}/paid + body: none + auth: none +} + +settings { + encodeUrl: false +} + +tests { + test("302 redirect", function () { + expect(res.getStatus()).to.be.oneOf([302, 303, 307]); + }); + test("redirect contient ?checkin=", function () { + const loc = res.getHeader("location"); + expect(loc).to.match(/checkin=/); + }); +} + +docs { + GET /api/v1/checkin/:token/paid + + L'utilisateur clique "j'ai été payé" depuis son email check-in. + Effets côté API : + - CheckinTask : status='answered', answer='paid', answered_at=now + - Invoice : status='paid', paid_at=now, rubis_earned+1 + - Organization.rubis_count+1 + - ActivityEvent kind=invoice_paid (label "via check-in") + - Toutes les RelanceTask scheduled de cette facture → cancelled + + Idempotent : 2e click → redirect avec `?checkin=already_answered`. + + Redirect SPA : `${WEB_URL}/?checkin=paid&invoice=` + + Comment récupérer un `checkinToken` : + 1. Crée une facture (Invoices → 04 Create) avec dueDate dans le passé + 2. Attends ~1min, ouvre Mailpit http://localhost:8025 + 3. Copie le segment du token depuis l'URL "C'est payé" du mail + 4. Pose-le dans la var d'env `checkinToken` +} diff --git a/bruno/08-Checkin/02 Respond pending.bru b/bruno/08-Checkin/02 Respond pending.bru new file mode 100644 index 0000000..2371239 --- /dev/null +++ b/bruno/08-Checkin/02 Respond pending.bru @@ -0,0 +1,38 @@ +meta { + name: 02 Respond pending + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/checkin/{{checkinToken}}/pending + body: none + auth: none +} + +settings { + encodeUrl: false +} + +tests { + test("302 redirect", function () { + expect(res.getStatus()).to.be.oneOf([302, 303, 307]); + }); +} + +docs { + GET /api/v1/checkin/:token/pending + + L'utilisateur clique "toujours en attente" depuis son email check-in. + La facture reste pending, les relances suivent leur cours. + + Effets côté API : + - CheckinTask : status='answered', answer='still_pending', answered_at=now + + Redirect SPA : `${WEB_URL}/?checkin=pending&invoice=` + + Cas d'erreur (redirect avec `?checkin=...`) : + - `invalid` : token inconnu (mauvais token ou task purgée) + - `expired` : task envoyée il y a + de 24h + - `already_answered` : 2e click sur le même token +} diff --git a/bruno/08-Checkin/folder.bru b/bruno/08-Checkin/folder.bru new file mode 100644 index 0000000..9e5e384 --- /dev/null +++ b/bruno/08-Checkin/folder.bru @@ -0,0 +1,40 @@ +meta { + name: Checkin + seq: 9 +} + +docs { + ## Check-in — endpoints PUBLICS (pas d'auth Bearer) + + Le check-in est l'email envoyé à **l'utilisateur** (pas au client) pour + confirmer qu'une facture a été payée AVANT que la première relance ne + parte (cf. CLAUDE.md → Glossaire). Remplace l'intégration banking V1. + + ## Flow + + 1. Quand une facture avec un plan est créée, `scheduleCheckinForInvoice` + enqueue un BullMQ job à `dueDate` (pile à l'échéance). + 2. À l'exécution, `SendCheckinJob` envoie un email à l'user avec 2 + liens : "C'est payé" et "Toujours en attente". + 3. L'user clique → endpoint public ci-dessous → DB update + redirect SPA. + + ## Token + + - 32 bytes random base64url, généré au schedule. + - Stocké hashé (SHA-256) en DB (CheckinTask.token_hash). + - TTL 24h après envoi (sentAt + 24h). + - Une seule réponse possible (idempotent : 2e click → already_answered). + + ## Pourquoi pas d'auth Bearer ? + + L'utilisateur clique depuis sa boîte mail — pas de session JS active. + Le token est l'authentification : sa connaissance vaut autorisation + (avec TTL 24h pour limiter la fenêtre de risque). + + ## Pour tester + + Crée une facture avec dueDate dans le passé (Invoices → 04 Create) : + le check-in est programmé à `now + 1min`. ~1min plus tard, regarde + Mailpit http://localhost:8025 → tu vois l'email avec les 2 liens. + Copie le token de l'URL et utilise les requêtes Bruno ci-dessous. +} diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru index c7cffc1..a645ebb 100644 --- a/bruno/environments/local.bru +++ b/bruno/environments/local.bru @@ -11,4 +11,5 @@ vars { invoiceId: batchId: draftId: + checkinToken: }