Compare commits

...

2 Commits

Author SHA1 Message Date
ordinarthur
68ed8f2ec6 feat(api): logs fichier en dev + traces du flow relance/mail
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
config/logger.ts: en dev, on duplique les logs vers
apps/api/storage/logs/app.log via un `multistream` pino (pretty stdout
+ JSON file en parallèle). Plus fiable que `transport.targets` qui
tourne dans un worker thread et fail silencieusement quand le path
n'est pas accessible.

Logs ciblés sur le pipeline relance pour debug rapide :
  - relance_scheduler : tâche créée + delaySec + queueJobId
  - send_relance_job  : pick-up / skip / envoi / OK / KO
  - mail_dispatcher   : driver actif (smtp/resend) + send OK / err

.gitignore : storage/uploads + storage/logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:37:20 +02:00
ordinarthur
92a9fac62b 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>
2026-05-07 12:37:09 +02:00
11 changed files with 668 additions and 25 deletions

4
apps/api/.gitignore vendored
View File

@ -24,3 +24,7 @@ yarn-error.log
# Platform specific
.DS_Store
# Storage (uploads locaux, logs dev)
storage/uploads
storage/logs

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

@ -20,16 +20,21 @@ import logger from '@adonisjs/core/services/logger'
* - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées).
*/
export async function sendRelanceJob(jobData: { taskId: string }) {
logger.info({ taskId: jobData.taskId }, 'sendRelanceJob: pick-up')
const task = await RelanceTask.query()
.where('id', jobData.taskId)
.preload('planStep')
.first()
if (!task) {
logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping')
logger.warn({ taskId: jobData.taskId }, 'sendRelanceJob: task not found, skipping')
return
}
if (task.status !== 'scheduled') {
logger.info({ taskId: task.id, status: task.status }, 'relance task not scheduled, skipping')
logger.info(
{ taskId: task.id, status: task.status },
'sendRelanceJob: task not scheduled (already sent / cancelled), skipping'
)
return
}
@ -39,6 +44,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
.preload('organization')
.first()
if (!invoice) {
logger.warn(
{ taskId: task.id, invoiceId: task.invoiceId },
'sendRelanceJob: invoice not found, cancelling task'
)
task.status = 'cancelled'
await task.save()
return
@ -47,6 +56,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
// Hook critique : la facture peut avoir été payée entre la programmation
// et l'exécution. On vérifie avant d'envoyer.
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
logger.info(
{ taskId: task.id, invoiceId: invoice.id, status: invoice.status },
'sendRelanceJob: invoice paid/cancelled between schedule and execution, cancelling task'
)
task.status = 'cancelled'
await task.save()
return
@ -79,6 +92,17 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
}
// Envoi normal
logger.info(
{
taskId: task.id,
invoiceId: invoice.id,
numero: invoice.numero,
to: invoice.client.email,
stepOrder: step.order,
offsetDays: step.offsetDays,
},
'sendRelanceJob: envoi mail relance'
)
await sendRelanceEmail({
invoice,
client: invoice.client,
@ -86,6 +110,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
user,
organization: invoice.organization,
})
logger.info(
{ taskId: task.id, invoiceId: invoice.id, numero: invoice.numero },
'sendRelanceJob: mail relance envoyé OK'
)
const sentAt = await clock.now(invoice.organizationId)
await db.transaction(async (trx) => {

View File

@ -1,4 +1,5 @@
import mail from '@adonisjs/mail/services/main'
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import { DateTime } from 'luxon'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
@ -112,23 +113,53 @@ export async function sendRelanceEmail({
body,
meta: { invoiceId: invoice.id, clientId: client.id, stepOrder: step.order },
})
if (captured) return // demo : ne pas envoyer pour de vrai
if (captured) {
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero, to: client.email },
'sendRelanceEmail: capturé en mode démo (pas d\'envoi réel)'
)
return // demo : ne pas envoyer pour de vrai
}
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
// décidé d'un look graphique pour les relances.
.text(body)
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
// notre boîte transactionnelle.
if (user?.email) {
m.replyTo(user.email, user.fullName ?? user.email)
}
})
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
to: client.email,
from: fromAddress,
driver,
subjectPreview: subject.slice(0, 80),
},
'sendRelanceEmail: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
// décidé d'un look graphique pour les relances.
.text(body)
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
// notre boîte transactionnelle.
if (user?.email) {
m.replyTo(user.email, user.fullName ?? user.email)
}
})
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero, driver },
'sendRelanceEmail: send OK'
)
} catch (err) {
logger.error(
{ err, invoiceId: invoice.id, numero: invoice.numero, driver },
'sendRelanceEmail: échec envoi'
)
throw err
}
}
type CheckinPayload = {

View File

@ -4,6 +4,7 @@ import type Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import * as clock from '#services/clock'
import app from '@adonisjs/core/services/app'
import logger from '@adonisjs/core/services/logger'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
const RELANCE_QUEUE = 'relances'
@ -50,6 +51,14 @@ export async function scheduleRelancesForInvoice(
.where('invoice_id', invoice.id)
.whereIn('status', ['scheduled', 'sent'])
if (alreadyActive.length > 0) {
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
existingTasks: alreadyActive.length,
},
'scheduleRelancesForInvoice: tasks déjà actives, no-op'
)
return alreadyActive
}
@ -126,6 +135,39 @@ export async function scheduleRelancesForInvoice(
task.queueJobId = job?.id ?? null
await task.save()
created.push(task)
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
stepOrder: step.order,
offsetDays: step.offsetDays,
sendAt: sendAt.toISO(),
delayMs: delay,
delaySec: Math.round(delay / 1000),
taskId: task.id,
queueJobId: task.queueJobId,
enqueued: queue !== null,
},
'scheduleRelancesForInvoice: tâche créée + job BullMQ enqueué'
)
}
if (created.length === 0) {
logger.warn(
{ invoiceId: invoice.id, numero: invoice.numero, planId: plan.id, stepCount: steps.length },
'scheduleRelancesForInvoice: aucune tâche créée (plan vide ?)'
)
} else {
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
taskCount: created.length,
enqueueEnabled: queue !== null,
},
'scheduleRelancesForInvoice: terminé'
)
}
return created

View File

@ -1,6 +1,52 @@
import { mkdirSync } from 'node:fs'
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
import {
defineConfig,
syncDestination,
targets,
multistream,
destination,
} from '@adonisjs/core/logger'
/**
* En dev on duplique les logs vers `apps/api/storage/logs/app.log` pour
* pouvoir grep / tail / partager facilement (notamment quand un job BullMQ
* tourne en arrière-plan et qu'on veut voir si l'envoi mail s'est bien fait
* sans devoir scroller le terminal).
*
* En prod on garde uniquement stdout (les logs sont collectés par K8s via
* `kubectl logs`).
*
* Implémentation : on utilise `multistream` (pino) qui écrit en parallèle
* - vers la sortie sync pretty (TTY) la sortie habituelle dev
* - vers le fichier app.log en JSON (parseable, grep-able)
*
* Plus fiable que `transport.targets` qui tourne dans un worker thread et
* peut échouer silencieusement quand le path n'est pas accessible.
*/
const logFilePath = app.makePath('storage/logs/app.log')
if (!app.inProduction) {
mkdirSync(app.makePath('storage/logs'), { recursive: true })
}
const prettyStream = !app.inProduction ? await syncDestination() : null
const devDestination =
!app.inProduction && prettyStream
? multistream([
// Sortie console pretty (sync) — c'est la sortie habituelle dev.
{ stream: prettyStream },
// Fichier JSON ligne — destination pino classique avec append+mkdir.
{
stream: destination({
dest: logFilePath,
sync: true,
append: true,
mkdir: true,
}),
},
])
: undefined
const loggerConfig = defineConfig({
/**
@ -26,16 +72,19 @@ const loggerConfig = defineConfig({
level: env.get('LOG_LEVEL'),
/**
* Use sync destination in non-production for immediate flush.
* Dev : multistream (pretty stdout + JSON file).
* Prod : pas de destination AdonisJS bascule sur transport.
*/
destination: !app.inProduction ? await syncDestination() : undefined,
destination: devDestination,
/**
* Configure where logs are written.
* En prod : un seul transport vers stdout (collecté par K8s).
* En dev : pas de transport le multistream ci-dessus s'occupe de
* tout, et mélanger les deux fait que pino ignore le destination.
*/
transport: {
targets: [targets.file({ destination: 1 })],
},
transport: app.inProduction
? { targets: [targets.file({ destination: 1 })] }
: undefined,
},
},
})

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>
);
}