diff --git a/apps/api/app/controllers/checkin_controller.ts b/apps/api/app/controllers/checkin_controller.ts index 5da66ab..a115d0f 100644 --- a/apps/api/app/controllers/checkin_controller.ts +++ b/apps/api/app/controllers/checkin_controller.ts @@ -1,5 +1,6 @@ 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' @@ -7,6 +8,20 @@ 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 @@ -140,4 +155,150 @@ export default class CheckinController { 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' }) + } + + 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 (invoice.status !== 'paid') { + 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) + } + }) + + 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) }) + } } diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 07551ef..d232824 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -53,6 +53,33 @@ router .prefix('auth') .as('auth') + /** + * Check-in in-app — auth requise. La modale au login liste les factures + * en attente de confirmation et permet à l'user d'y répondre directement + * sans passer par l'email. + * + * IMPORTANT : ce groupe doit être déclaré AVANT le groupe public à token, + * sinon `/checkin/:token/pending` mange `/checkin/inapp/pending` (token + * littéral = "inapp") et redirige vers le SPA en 302. + */ + router + .group(() => { + router + .get('pending', [controllers.Checkin, 'inappPending']) + .as('pending') + router + .post(':invoiceId/paid', [controllers.Checkin, 'inappRespondPaid']) + .as('paid') + .where('invoiceId', router.matchers.uuid()) + router + .post(':invoiceId/pending', [controllers.Checkin, 'inappRespondPending']) + .as('pending.post') + .where('invoiceId', router.matchers.uuid()) + }) + .prefix('checkin/inapp') + .as('checkin.inapp') + .use(middleware.auth()) + /** * Check-in — public (pas d'auth Bearer). Token signé en URL, * lookup hash en DB. Redirige vers le SPA avec ?checkin=... pour diff --git a/apps/web/src/components/checkin/InAppCheckinModal.tsx b/apps/web/src/components/checkin/InAppCheckinModal.tsx new file mode 100644 index 0000000..abc5bdb --- /dev/null +++ b/apps/web/src/components/checkin/InAppCheckinModal.tsx @@ -0,0 +1,233 @@ +import { useMemo, useState } from "react"; +import { Check, AlertCircle, ArrowRight, FileText, Calendar } from "lucide-react"; +import { toast } from "sonner"; + +import { + usePendingCheckins, + useCheckinPaid, + useCheckinStillPending, + type PendingCheckinInvoice, +} from "@/lib/checkin"; +import { formatDate, formatDueDelta, formatEuros, isOverdue } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; + +const SESSION_DISMISS_KEY = "rubis.checkin.dismissed"; + +/** + * Modale qui se déclenche au login si l'org a des factures en + * `awaiting_user_confirmation`. Pour chacune, l'user répond directement : + * - "Oui, payée" → mark paid + cancel relances + * - "Non, impayée" → schedule relances + status → in_relance + * - "Plus tard" → on passe à la suivante, on ne touche pas la facture + * + * Stratégie de queue : on s'appuie sur le serveur comme source de vérité. + * Après chaque réponse, l'invoice quitte le statut `awaiting_user_confirmation` + * et disparaît du refetch. On affiche **toujours queue[0]**, donc la + * suivante remonte naturellement à la position 0 — pas de cursor à gérer. + * + * Pour "Plus tard" (skip), on garde un set local d'IDs ignorés cette + * session, qu'on filtre côté client (le serveur les retournera toujours + * tant qu'elles sont awaiting_user_confirmation). + */ +export function InAppCheckinModal() { + const { data: pending = [], isLoading } = usePendingCheckins(); + const paidMutation = useCheckinPaid(); + const stillPendingMutation = useCheckinStillPending(); + + // IDs ignorés cette session (skip "Plus tard"). Persiste en mémoire + // pendant la vie du composant, perdu au refresh — ce qui est le but : + // l'user retombe dessus au prochain login. + const [skipped, setSkipped] = useState>(new Set()); + + // sessionStorage flag — true = l'user a explicitement fermé (X), on + // ne ré-ouvre pas tant qu'il ne reload pas l'onglet. + const [dismissed, setDismissed] = useState(() => { + if (typeof window === "undefined") return false; + return sessionStorage.getItem(SESSION_DISMISS_KEY) === "1"; + }); + + // Queue = pending serveur, moins les skippés locaux. Ordre serveur + // (échéance asc) préservé. + const queue = useMemo( + () => pending.filter((p) => !skipped.has(p.id)), + [pending, skipped], + ); + const current = queue[0]; + const totalSeen = pending.length; // utilisé pour le compteur "X / Y" + const positionLeft = queue.length; + + const shouldOpen = !isLoading && !dismissed && queue.length > 0; + + const handleClose = () => { + sessionStorage.setItem(SESSION_DISMISS_KEY, "1"); + setDismissed(true); + }; + + const onPaid = () => { + if (!current) return; + paidMutation.mutate(current.id, { + onSuccess: () => { + toast.success(`${current.numero} marquée encaissée. + 1 rubis.`); + // Le refetch va retirer cette invoice de pending — current devient + // automatiquement la suivante (queue[0]). + }, + onError: () => + toast.error("Impossible de marquer la facture. Réessayez."), + }); + }; + + const onStillPending = () => { + if (!current) return; + stillPendingMutation.mutate(current.id, { + onSuccess: () => { + toast.success(`Relances activées pour ${current.numero}.`); + }, + onError: () => + toast.error("Impossible de programmer les relances. Réessayez."), + }); + }; + + const onSkip = () => { + if (!current) return; + setSkipped((prev) => { + const next = new Set(prev); + next.add(current.id); + return next; + }); + }; + + if (!current) return null; + + const isPending = + paidMutation.isPending || stillPendingMutation.isPending; + // Position courante = totalSeen - positionLeft + 1, pour avoir "1/3, 2/3…" + // même si la queue rétrécit après chaque réponse. + const cursorLabel = `${totalSeen - positionLeft + 1} / ${totalSeen}`; + const remaining = positionLeft - 1; + + return ( + { + if (!open) handleClose(); + }} + > + + +

+ Confirmation · {cursorLabel} +

+ + Avez-vous été payé sur cette facture ? + + + Aucune relance ne part sans votre validation. Si la facture est + réglée, on évite l'email inutile et on encaisse +1 rubis. + +
+ + + +
+ + +
+ +
+ + {remaining > 0 && ( +

+

+ )} +
+
+
+ ); +} + +/** Petite fiche récap de la facture concernée. */ +function InvoiceCard({ invoice }: { invoice: PendingCheckinInvoice }) { + const isLate = isOverdue(invoice.dueDate); + const dueLabel = formatDueDelta(invoice.dueDate); + + return ( +
+
+
+
+
+

{invoice.clientName}

+
+
+
+

+ {formatEuros(invoice.amountTtcCents)} +

+
+
+
+

+ {dueLabel} +

+
+
+ {invoice.planName && ( +

+ Plan : {invoice.planName} +

+ )} +
+ ); +} diff --git a/apps/web/src/lib/checkin.ts b/apps/web/src/lib/checkin.ts new file mode 100644 index 0000000..606df2c --- /dev/null +++ b/apps/web/src/lib/checkin.ts @@ -0,0 +1,62 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { queryKeys } from "@/lib/queryKeys"; +import type { InvoiceStatus } from "@rubis/shared"; + +/** + * Forme minimale renvoyée par GET /api/v1/checkin/inapp/pending — basée sur + * InvoiceTransformer côté API. On ne garde que ce dont la modale a besoin. + */ +export type PendingCheckinInvoice = { + id: string; + numero: string; + amountTtcCents: number; + issueDate: string; + dueDate: string; + status: InvoiceStatus; + clientName: string; + planName: string | null; +}; + +/** Liste des factures en attente de check-in pour l'org courante. */ +export function usePendingCheckins() { + return useQuery({ + queryKey: queryKeys.checkin.pending(), + queryFn: () => + api.get("/api/v1/checkin/inapp/pending"), + // Pas de polling — la liste change uniquement quand l'user répond ou + // qu'une nouvelle invoice arrive en awaiting_user_confirmation. On + // refetch sur mount + sur invalidate. + staleTime: 30_000, + }); +} + +/** Mutation : "oui, payée" — délègue à l'endpoint inappRespondPaid. */ +export function useCheckinPaid() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (invoiceId: string) => + api.post(`/api/v1/checkin/inapp/${invoiceId}/paid`), + onSuccess: () => { + // Tout l'écosystème dépend du statut : invalidate large (factures, + // dashboard KPIs, timeseries, pipeline, counts). + void qc.invalidateQueries({ queryKey: queryKeys.checkin.pending() }); + void qc.invalidateQueries({ queryKey: queryKeys.invoices.all() }); + void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() }); + }, + }); +} + +/** Mutation : "non, toujours impayée" — programme les relances. */ +export function useCheckinStillPending() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (invoiceId: string) => + api.post(`/api/v1/checkin/inapp/${invoiceId}/pending`), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: queryKeys.checkin.pending() }); + void qc.invalidateQueries({ queryKey: queryKeys.invoices.all() }); + void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() }); + }, + }); +} diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 73e7c11..2ab2224 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -25,4 +25,8 @@ export const queryKeys = { kpis: () => ["dashboard", "kpis"] as const, activity: () => ["dashboard", "activity"] as const, }, + checkin: { + all: () => ["checkin"] as const, + pending: () => ["checkin", "pending"] as const, + }, } as const; diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 0da88a1..b24cd10 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -2,6 +2,7 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { authStore } from "@/lib/auth"; import { AppLayout } from "@/components/layout/AppLayout"; +import { InAppCheckinModal } from "@/components/checkin/InAppCheckinModal"; /** * `_app` — layout pathless pour l'app authentifiée. @@ -29,6 +30,7 @@ function AppRouteComponent() { return ( + ); }