import CheckinTask from '#models/checkin_task' import Invoice from '#models/invoice' import InvoiceTransformer from '#transformers/invoice_transformer' import { hashCheckinToken } from '#services/checkin_token' import { recordActivity } from '#services/activity_recorder' import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler' import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher' import * as clock from '#services/clock' import db from '@adonisjs/lucid/services/db' import env from '#start/env' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' /** Garde org-id sur l'auth — partagé avec invoices_controller, gardé inline ici. */ 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 serializeInvoice(invoice: Invoice) { return new InvoiceTransformer(invoice).toObject() } 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', invoice?: Pick ): string { const base = env.get('WEB_URL', 'http://localhost:5173') const params = new URLSearchParams({ checkin: result }) if (invoice) params.set('invoice', invoice.numero) const path = invoice ? `/factures/${invoice.id}` : '/' return `${base}${path}?${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 ?? undefined) } } // 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 }) < (await clock.now(task.organizationId))) { 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 // Capture la transition `* → paid` AVANT le commit pour décider d'enqueuer // le remerciement (idempotence : si la facture était déjà payée, on // n'envoie pas un 2e thanks). const wasUnpaid = invoice.status !== 'paid' await db.transaction(async (trx) => { const nowOrg = await clock.now(invoice.organizationId) task.useTransaction(trx) task.status = 'answered' task.answer = 'paid' task.answeredAt = nowOrg await task.save() // Mark paid (mêmes effets que POST /invoices/:id/mark-paid). if (wasUnpaid) { invoice.useTransaction(trx) invoice.status = 'paid' invoice.paidAt = nowOrg 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 confirmation`, meta: { invoiceId: invoice.id, clientId: invoice.clientId }, trx, }) await cancelFutureRelances(invoice.id, trx) } }) // Enqueue après le commit — l'envoi est asynchrone, sans bloquer le // redirect SPA. Skippé silencieusement si Redis est down. if (wasUnpaid) { await enqueuePaymentThanks(invoice.id) } return response.redirect(spaRedirectUrl('paid', invoice)) } /** * GET /api/v1/checkin/:token/pending * * L'utilisateur clique "toujours en attente". On marque la task * answered, puis on programme les relances client. */ async respondPending({ 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) => { if (invoice.planId) { invoice.useTransaction(trx) await scheduleRelancesForInvoice(invoice, trx) } task.useTransaction(trx) task.status = 'answered' task.answer = 'still_pending' task.answeredAt = await clock.now(invoice.organizationId) await task.save() }) return response.redirect(spaRedirectUrl('pending', invoice)) } /** * GET /api/v1/checkin/inapp/pending — auth requise. * * Retourne les factures en `awaiting_user_confirmation` pour l'org de * l'user courant. La modale de check-in in-app les affiche au login pour * que l'user réponde "payée" / "toujours impayée" sans passer par mail. * * Tri : échéance croissante (les plus anciennes d'abord). */ async inappPending({ auth, response }: HttpContext) { const organizationId = requireOrgId(auth) const invoices = await Invoice.query() .where('organization_id', organizationId) .where('status', 'awaiting_user_confirmation') .preload('client') .preload('plan') .orderBy('due_date', 'asc') return response.json({ data: invoices.map(serializeInvoice) }) } /** * POST /api/v1/checkin/inapp/:invoiceId/paid — auth requise. * * Réponse "oui, payée" en in-app. Effets identiques au flow mail : * - mark facture paid + bonus rubis + cancel relances futures * - mark CheckinTask answered/paid si elle existe (idempotent sinon) * - record activity event */ async inappRespondPaid({ auth, params, response }: HttpContext) { const organizationId = requireOrgId(auth) const invoice = await Invoice.query() .where('organization_id', organizationId) .where('id', params.invoiceId) .preload('client') .preload('plan') .first() if (!invoice) { throw new Exception('Facture introuvable', { status: 404, code: 'not_found' }) } const wasUnpaid = invoice.status !== 'paid' await db.transaction(async (trx) => { const nowOrg = await clock.now(invoice.organizationId) // Mark CheckinTask answered (si elle existe — peut être null si l'user // déclenche le check-in in-app avant que l'email scheduler ait tourné). const task = await CheckinTask.query({ client: trx }) .where('invoice_id', invoice.id) .whereIn('status', ['scheduled', 'sent']) .first() if (task) { task.useTransaction(trx) task.status = 'answered' task.answer = 'paid' task.answeredAt = nowOrg await task.save() } if (wasUnpaid) { invoice.useTransaction(trx) invoice.status = 'paid' invoice.paidAt = nowOrg 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 confirmation`, meta: { invoiceId: invoice.id, clientId: invoice.clientId }, trx, }) await cancelFutureRelances(invoice.id, trx) } }) if (wasUnpaid) { await enqueuePaymentThanks(invoice.id) } return response.json({ data: serializeInvoice(invoice) }) } /** * POST /api/v1/checkin/inapp/:invoiceId/pending — auth requise. * * Réponse "non, toujours impayée" en in-app. Effets : * - mark CheckinTask answered/still_pending si elle existe * - schedule les relances client (BullMQ + RelanceTask) * - bascule invoice.status → in_relance (l'user voit immédiatement * la facture sortir du état d'attente, sans devoir attendre le * premier envoi) * - record activity event */ async inappRespondPending({ auth, params, response }: HttpContext) { const organizationId = requireOrgId(auth) const invoice = await Invoice.query() .where('organization_id', organizationId) .where('id', params.invoiceId) .preload('client') .preload('plan', (q) => q.preload('steps')) .first() if (!invoice) { throw new Exception('Facture introuvable', { status: 404, code: 'not_found' }) } await db.transaction(async (trx) => { const nowOrg = await clock.now(invoice.organizationId) const task = await CheckinTask.query({ client: trx }) .where('invoice_id', invoice.id) .whereIn('status', ['scheduled', 'sent']) .first() if (task) { task.useTransaction(trx) task.status = 'answered' task.answer = 'still_pending' task.answeredAt = nowOrg await task.save() } if (invoice.planId) { invoice.useTransaction(trx) await scheduleRelancesForInvoice(invoice, trx) } invoice.useTransaction(trx) invoice.status = 'in_relance' await invoice.save() await recordActivity({ organizationId: invoice.organizationId, kind: 'relance_sent', label: `Relances activées pour la facture ${invoice.numero}`, meta: { invoiceId: invoice.id, clientId: invoice.clientId }, trx, }) }) return response.json({ data: serializeInvoice(invoice) }) } }