From b8dec6d49458fc0dc3039504672d82fc93ca8eab Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 19:02:39 +0200 Subject: [PATCH] update la relance par mail --- .gitignore | 1 + .../api/app/controllers/checkin_controller.ts | 31 ++++---- apps/api/app/services/relance_scheduler.ts | 7 ++ apps/web/src/routes/_app/factures_.$id.tsx | 71 ++++++++++++------- 4 files changed, 73 insertions(+), 37 deletions(-) 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 && (