diff --git a/.gitignore b/.gitignore
index aa7de9a..fbd9f0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
.DS_Store
node_modules/
+assets/test-invoices/
# Env files (never commit secrets)
.env
diff --git a/apps/api/app/controllers/checkin_controller.ts b/apps/api/app/controllers/checkin_controller.ts
index 8311f90..829e656 100644
--- a/apps/api/app/controllers/checkin_controller.ts
+++ b/apps/api/app/controllers/checkin_controller.ts
@@ -16,12 +16,13 @@ const CHECKIN_TTL_HOURS = 24
*/
function spaRedirectUrl(
result: 'paid' | 'pending' | 'expired' | 'invalid' | 'already_answered',
- invoiceNumero?: string
+ invoice?: Pick
): string {
const base = env.get('WEB_URL', 'http://localhost:5173')
const params = new URLSearchParams({ checkin: result })
- if (invoiceNumero) params.set('invoice', invoiceNumero)
- return `${base}/?${params.toString()}`
+ 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 }
@@ -39,7 +40,7 @@ async function resolveCheckin(token: string): Promise {
if (task.status === 'answered') {
const inv = await Invoice.find(task.invoiceId)
- return { redirect: spaRedirectUrl('already_answered', inv?.numero) }
+ return { redirect: spaRedirectUrl('already_answered', inv ?? undefined) }
}
// Expiration : 24h après l'envoi (sentAt). Tant qu'elle n'a pas été
@@ -107,7 +108,7 @@ export default class CheckinController {
}
})
- return response.redirect(spaRedirectUrl('paid', invoice.numero))
+ return response.redirect(spaRedirectUrl('paid', invoice))
}
/**
@@ -123,15 +124,19 @@ export default class CheckinController {
}
const { task, invoice } = result
- task.status = 'answered'
- task.answer = 'still_pending'
- task.answeredAt = DateTime.now()
- await task.save()
+ await db.transaction(async (trx) => {
+ if (invoice.planId) {
+ invoice.useTransaction(trx)
+ await scheduleRelancesForInvoice(invoice, trx)
+ }
- if (invoice.planId) {
- await scheduleRelancesForInvoice(invoice)
- }
+ task.useTransaction(trx)
+ task.status = 'answered'
+ task.answer = 'still_pending'
+ task.answeredAt = DateTime.now()
+ await task.save()
+ })
- return response.redirect(spaRedirectUrl('pending', invoice.numero))
+ return response.redirect(spaRedirectUrl('pending', invoice))
}
}
diff --git a/apps/api/app/services/relance_scheduler.ts b/apps/api/app/services/relance_scheduler.ts
index 621948a..db375ab 100644
--- a/apps/api/app/services/relance_scheduler.ts
+++ b/apps/api/app/services/relance_scheduler.ts
@@ -46,6 +46,13 @@ export async function scheduleRelancesForInvoice(
.first()
if (!plan) return []
+ const alreadyActive = await RelanceTask.query(trx ? { client: trx } : undefined)
+ .where('invoice_id', invoice.id)
+ .whereIn('status', ['scheduled', 'sent'])
+ if (alreadyActive.length > 0) {
+ return alreadyActive
+ }
+
// Cancel les tasks scheduled existantes (re-scheduling après changement
// de plan ou de dueDate).
const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
diff --git a/apps/web/src/routes/_app/factures_.$id.tsx b/apps/web/src/routes/_app/factures_.$id.tsx
index aee1cec..19c5c38 100644
--- a/apps/web/src/routes/_app/factures_.$id.tsx
+++ b/apps/web/src/routes/_app/factures_.$id.tsx
@@ -1,17 +1,14 @@
+import { useEffect } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, Send } from "lucide-react";
import { toast } from "sonner";
+import { z } from "zod";
import type { Client, Invoice, Plan } from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
-import {
- formatEuros,
- formatDate,
- formatDueDelta,
- isOverdue,
-} from "@/lib/format";
+import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -20,6 +17,11 @@ import { StatusBadge } from "@/components/ui/StatusBadge";
import { Timeline, type TimelineEvent } from "@/components/ui/Timeline";
import { Textarea } from "@/components/ui/Textarea";
+const checkinSearchSchema = z.object({
+ checkin: z.enum(["paid", "pending", "expired", "invalid", "already_answered"]).optional(),
+ invoice: z.string().optional(),
+});
+
type InvoiceDetail = Invoice & {
client: Client;
plan: Plan | null;
@@ -29,6 +31,7 @@ type InvoiceDetail = Invoice & {
};
export const Route = createFileRoute("/_app/factures_/$id")({
+ validateSearch: checkinSearchSchema,
component: InvoiceDetailPage,
loader: ({ context, params }) => {
void context.queryClient.prefetchQuery({
@@ -40,10 +43,15 @@ export const Route = createFileRoute("/_app/factures_/$id")({
function InvoiceDetailPage() {
const { id } = Route.useParams();
+ const search = Route.useSearch();
const queryClient = useQueryClient();
const navigate = useNavigate();
- const { data: invoice, isPending, isError } = useQuery({
+ const {
+ data: invoice,
+ isPending,
+ isError,
+ } = useQuery({
queryKey: queryKeys.invoices.detail(id),
queryFn: () => api.get(`/api/v1/invoices/${id}`),
});
@@ -61,12 +69,37 @@ function InvoiceDetailPage() {
},
});
+ useEffect(() => {
+ const checkin = search.checkin;
+ if (!checkin) return;
+
+ const labels: Record = {
+ paid: "Facture marquée encaissée.",
+ pending: "Relance activée pour cette facture.",
+ expired: "Ce lien de check-in a expiré.",
+ invalid: "Ce lien de check-in est invalide.",
+ already_answered: "Ce check-in avait déjà été traité.",
+ };
+
+ const isErrorToast = checkin === "expired" || checkin === "invalid";
+ if (isErrorToast) toast.error(labels[checkin]);
+ else toast.success(labels[checkin]);
+
+ void queryClient.invalidateQueries({ queryKey: queryKeys.invoices.detail(id) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.kpis() });
+ void navigate({
+ to: "/factures/$id",
+ params: { id },
+ search: {},
+ replace: true,
+ });
+ }, [id, navigate, queryClient, search.checkin]);
+
if (isError) {
return (
-
- Facture introuvable.
-
+
Facture introuvable.
-
+
@@ -147,9 +176,7 @@ function InvoiceDetailPage() {
Timeline
- {invoice.plan && (
- · plan {invoice.plan.name}
- )}
+ {invoice.plan && · plan {invoice.plan.name}}
@@ -162,14 +189,10 @@ function InvoiceDetailPage() {
{invoice.client.name}
{invoice.client.email && (
-
- {invoice.client.email}
-
+ {invoice.client.email}
)}
{invoice.client.phone && (
-
- {invoice.client.phone}
-
+ {invoice.client.phone}
)}
{invoice.client.address && (