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, scheduleRelancesForInvoice } 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', 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 }) < 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)) } /** * 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 = DateTime.now() await task.save() }) return response.redirect(spaRedirectUrl('pending', invoice)) } }