feat(checkin): modale in-app pour confirmer le paiement au login

Permet à l'user de répondre aux check-ins directement dans l'app, sans
passer par les liens email. Au mount du layout `_app`, on liste les
factures en `awaiting_user_confirmation` et on les présente une par une
dans une modale séquentielle :

  - "Oui, payée"     → mark paid + bonus rubis + cancel relances
  - "Non, en attente" → schedule relances + status → in_relance
  - "Plus tard"       → skip session-only

3 endpoints auth-protected sous /api/v1/checkin/inapp/ (déclarés AVANT
le groupe public à token sinon /:token/pending mange /inapp/pending).

La modale fait toujours confiance au serveur : queue = pending refetch,
display = queue[0], pas de cursor manuel — sinon on saute des factures
quand le serveur retire la réponse précédente.

Wording rassurant : "Aucune relance ne part sans votre validation".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 12:37:09 +02:00
parent 89c9a732d6
commit 92a9fac62b
6 changed files with 489 additions and 0 deletions

View File

@ -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 <b>${invoice.numero}</b> 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 <b>${invoice.numero}</b>`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
})
return response.json({ data: serializeInvoice(invoice) })
}
}

View File

@ -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

View File

@ -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<Set<string>>(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<boolean>(() => {
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<PendingCheckinInvoice[]>(
() => 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 (
<Dialog
open={shouldOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent maxWidth={520}>
<DialogHeader>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Confirmation · {cursorLabel}
</p>
<DialogTitle className="mt-1">
Avez-vous é <em className="text-rubis not-italic">payé</em> sur cette facture ?
</DialogTitle>
<DialogDescription className="mt-1.5">
Aucune relance ne part sans votre validation. Si la facture est
réglée, on évite l'email inutile et on encaisse +1 rubis.
</DialogDescription>
</DialogHeader>
<InvoiceCard invoice={current} />
<div className="mt-5 flex flex-col gap-2.5">
<Button
size="md"
variant="primary"
loading={paidMutation.isPending}
disabled={isPending}
onClick={onPaid}
className="w-full justify-start"
>
<Check size={15} aria-hidden="true" />
Oui la facture est payée
</Button>
<Button
size="md"
variant="secondary"
loading={stillPendingMutation.isPending}
disabled={isPending}
onClick={onStillPending}
className="w-full justify-start"
>
<AlertCircle size={15} aria-hidden="true" />
Non toujours en attente, lance les relances
</Button>
</div>
<div className="mt-4 flex items-center justify-between">
<button
type="button"
onClick={onSkip}
disabled={isPending}
className={cn(
"text-[12.5px] text-ink-3 hover:text-rubis underline-offset-4 hover:underline cursor-pointer",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
>
Plus tard passer à la suivante
</button>
{remaining > 0 && (
<p className="text-[11.5px] text-ink-3 italic flex items-center gap-1">
<ArrowRight size={11} aria-hidden="true" />
{remaining} autre{remaining > 1 ? "s" : ""} après
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
/** 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 (
<div className="rounded-card border border-line bg-white px-4 py-3.5">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<FileText size={13} className="text-ink-3 shrink-0" aria-hidden="true" />
<p className="font-display text-[14px] font-semibold tracking-tight text-ink truncate">
{invoice.numero}
</p>
</div>
<p className="text-[12.5px] text-ink-2 truncate">{invoice.clientName}</p>
</div>
</div>
<div className="flex items-end justify-between gap-3">
<p className="font-display text-[22px] font-bold tabular-nums leading-none text-ink">
{formatEuros(invoice.amountTtcCents)}
</p>
<div className="text-right">
<div className="flex items-center gap-1 justify-end text-[11.5px] text-ink-3 tabular-nums">
<Calendar size={11} aria-hidden="true" />
<span>échue le {formatDate(invoice.dueDate)}</span>
</div>
<p
className={cn(
"mt-0.5 text-[11.5px] font-medium tabular-nums",
isLate ? "text-rubis-deep" : "text-ink-3",
)}
>
{dueLabel}
</p>
</div>
</div>
{invoice.planName && (
<p className="mt-3 pt-3 border-t border-line text-[11.5px] text-ink-3">
Plan : <strong className="font-medium text-ink-2">{invoice.planName}</strong>
</p>
)}
</div>
);
}

View File

@ -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<PendingCheckinInvoice[]>("/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() });
},
});
}

View File

@ -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;

View File

@ -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 (
<AppLayout>
<Outlet />
<InAppCheckinModal />
</AppLayout>
);
}