feat(pagamenti): ordine stabile delle rate, label e importi modificabili

Mettere una rata su "saldato" la faceva saltare in fondo. Non era un'impressione:
payments non aveva NESSUNA colonna d'ordine (ne' sort_order ne' created_at) e
nessuna delle 13 query che la leggono aveva un ORDER BY. Postgres fa seq-scan e
restituisce l'ordine fisico; una UPDATE in MVCC riscrive la tupla in coda, quindi
la riga aggiornata tornava ultima.

In produzione 3 progetti su 5 mostravano gia' l'ordine sbagliato (uno 30/20/50,
uno del tutto rovesciato 20/30/50, e la coppia legacy con Saldo prima di Acconto).
Per questo la migration 0019 NON fa il backfill per ctid, che avrebbe fotografato
lo scombinamento: ordina per percent DESC con tie su label, che ricostruisce
l'intento di tutti gli schemi esistenti. Verificato: rimette a posto tutti e 5.

Aggiunto anche l'indice su (project_id, sort_order): una FK non crea indice sul
lato referenziante, e payments non ne aveva alcuno oltre alla PK.

Ora le rate si rinominano e gli importi si sovrascrivono a mano (EditableCell +
updatePaymentField, con la stessa normalizzazione it-IT di updateServiceField).
L'importo scritto a mano e' legge: amount_locked lo esclude dal ricalcolo. Quando
la somma delle rate non corrisponde al totale il tab lo dice, con la cifra esatta,
invece di aggiustare di nascosto.

Lo schema a 3 rate passa da 50/30/20 a 50/25/25 (le righe gia' esistenti non
cambiano: vale solo quando lo si riseleziona).

Due bug trovati per strada e chiusi:

- rescalePayments decideva con some(percent !== null): bastava UNA riga con
  percent per far entrare tutto il progetto nel ramo percentuale, che calcolava
  newTotal * 0 e azzerava ogni riga con percent NULL. splitPayment inserisce la
  Rata 2 proprio cosi', quindi splittare una rata e poi toccare il totale la
  portava a zero. Ora la regola e' per riga, non per progetto.
- Il selettore di schema fa DELETE+INSERT e cancellava in silenzio anche status e
  paid_at, con due rate gia' saldate in produzione. Ora chiede conferma, ma solo
  quando c'e' davvero storico da perdere.

Migration 0019 applicata a prod prima del push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 22:35:41 +02:00
parent df4671236a
commit b49d4bfaaa
8 changed files with 282 additions and 54 deletions
+86 -11
View File
@@ -312,34 +312,109 @@ export async function setPaymentPaidAt(paymentId: string, id: string, monthStr:
}
// Rescales payment amounts when the total changes.
// If the payment has a `percent` field, use it (new plan rows).
// Legacy rows (percent null) fall back to equal split across all rows.
//
// The rule is PER ROW, not per project. The previous version decided with
// `some((p) => p.percent !== null)`: a single row carrying a percent dragged the
// whole project into the percentage branch, where every percent-NULL row was
// computed as `newTotal * 0` and silently written to 0.00. That was a live bug —
// splitPayment inserts its second instalment with percent NULL, so splitting a
// payment and then touching the total zeroed the remainder.
//
// Now: a row is rescaled only if it still carries a percent AND has not been
// manually overridden. Everything else is left exactly as it is. The equal-split
// fallback survives only for projects where NO row has a percent (legacy stubs).
async function rescalePayments(projectId: string, newTotal: number): Promise<void> {
const projectPayments = await db
.select({ id: payments.id, percent: payments.percent })
.select({ id: payments.id, percent: payments.percent, amount_locked: payments.amount_locked })
.from(payments)
.where(eq(payments.project_id, projectId));
if (projectPayments.length === 0) return;
const hasPercent = projectPayments.some((p) => p.percent !== null);
const anyPercent = projectPayments.some((p) => p.percent !== null);
if (hasPercent) {
// New plan: rescale each row by its stored percent
if (anyPercent) {
for (const p of projectPayments) {
const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0;
const newAmount = ((newTotal * pct) / 100).toFixed(2);
if (p.percent === null || p.amount_locked) continue;
const newAmount = ((newTotal * parseFloat(String(p.percent))) / 100).toFixed(2);
await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id));
}
} else {
// Legacy: equal split across all rows (backward compat)
const share = (newTotal / projectPayments.length).toFixed(2);
for (const p of projectPayments) {
// Legacy: equal split across the rows that are still automatic.
const auto = projectPayments.filter((p) => !p.amount_locked);
if (auto.length === 0) return;
const share = (newTotal / auto.length).toFixed(2);
for (const p of auto) {
await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id));
}
}
}
// Per-row edit of a payment's label or amount, mirroring updateServiceField in
// src/app/admin/catalog/actions.ts. Writing an amount marks the row as manually
// overridden so rescalePayments stops touching it.
const EDITABLE_PAYMENT_FIELDS = ["label", "amount"] as const;
type EditablePaymentField = (typeof EDITABLE_PAYMENT_FIELDS)[number];
export async function updatePaymentField(
paymentId: string,
id: string,
fieldName: string,
value: string
): Promise<void> {
await requireAdmin();
if (!(EDITABLE_PAYMENT_FIELDS as readonly string[]).includes(fieldName)) {
throw new Error(`Campo non editabile: ${fieldName}`);
}
const field = fieldName as EditablePaymentField;
if (field === "label") {
const label = value.trim();
if (!label) throw new Error("Nome rata richiesto");
await db.update(payments).set({ label }).where(eq(payments.id, paymentId));
} else {
// The cell renders it-IT (€ 1.234,50), so an admin may well type "1.234,50".
// When a comma is present treat "." as thousands separators and "," as the
// decimal mark; otherwise "." is the decimal mark. Number() (not parseFloat)
// rejects trailing garbage like "12abc".
const raw = value.trim();
const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw;
const num = Number(normalized);
if (!Number.isFinite(num) || num < 0) throw new Error("Importo non valido");
await db
.update(payments)
.set({ amount: num.toFixed(2), amount_locked: true })
.where(eq(payments.id, paymentId));
}
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// Releases a manual override: the row goes back under automatic rescaling and is
// immediately recomputed from its percent, so the effect is visible at once.
export async function clearPaymentOverride(paymentId: string, id: string): Promise<void> {
await requireAdmin();
const rows = await db
.select({ project_id: payments.project_id })
.from(payments)
.where(eq(payments.id, paymentId))
.limit(1);
if (!rows[0]) throw new Error("Pagamento non trovato");
await db.update(payments).set({ amount_locked: false }).where(eq(payments.id, paymentId));
const proj = await db
.select({ accepted_total: projects.accepted_total })
.from(projects)
.where(eq(projects.id, rows[0].project_id))
.limit(1);
if (proj[0]) await rescalePayments(rows[0].project_id, parseFloat(proj[0].accepted_total ?? "0"));
const { path } = await resolveEntity(id);
revalidatePath(path);
}
export async function updateAcceptedTotal(id: string, formData: FormData) {
await requireAdmin();
const raw = (formData.get("accepted_total") as string)?.trim();
+5 -2
View File
@@ -86,9 +86,12 @@ export async function createClientCore(input: {
})
.returning({ id: projects.id });
// percent is seeded so these stubs rescale properly once a total is set —
// previously they were NULL, which put the project in the legacy equal-split
// branch and made them indistinguishable from a manually overridden amount.
await db.insert(payments).values([
{ project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare" },
{ project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare" },
{ project_id: newProject.id, label: "Acconto 50%", amount: "0", percent: "50.00", status: "da_saldare", sort_order: 0 },
{ project_id: newProject.id, label: "Saldo 50%", amount: "0", percent: "50.00", status: "da_saldare", sort_order: 1 },
]);
return { clientId: newClient.id, projectId: newProject.id };
+22 -6
View File
@@ -15,7 +15,7 @@ import {
tasks,
PROJECT_OFFER_STATUSES,
} from "@/db/schema";
import { eq, asc, and } from "drizzle-orm";
import { eq, asc, and, gt, sql } from "drizzle-orm";
import { z } from "zod";
import { nanoid } from "nanoid";
@@ -83,7 +83,7 @@ export async function initProjectPayments(projectId: string): Promise<void> {
// Modes:
// single → 1 row 100% "Pagamento unico (100%)"
// two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)"
// three → 3 rows 50%/30%/20%
// three → 3 rows 50%/25%/25%
export async function setPaymentPlan(
projectId: string,
mode: "single" | "two" | "three",
@@ -100,8 +100,8 @@ export async function setPaymentPlan(
],
three: [
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
{ label: "30% (post revisioni)", percent: 30 },
{ label: "Saldo 20% (alla consegna)", percent: 20 },
{ label: "25% (in corso d'opera)", percent: 25 },
{ label: "Saldo 25% (alla consegna)", percent: 25 },
],
};
@@ -109,14 +109,17 @@ export async function setPaymentPlan(
if (!plan) throw new Error("Modalità pagamento non valida");
// Delete existing payments for this project, then insert new ones.
// Destructive by design (the UI says so), and the tab asks for confirmation
// when a row has already been marked inviata/saldato.
await db.delete(payments).where(eq(payments.project_id, projectId));
await db.insert(payments).values(
plan.map((p) => ({
plan.map((p, i) => ({
project_id: projectId,
label: p.label,
percent: p.percent.toFixed(2),
amount: ((total * p.percent) / 100).toFixed(2),
status: "da_saldare" as const,
sort_order: i,
}))
);
@@ -145,16 +148,29 @@ export async function splitPayment(
// Strip any existing " Rata N" suffix so re-splits stay clean.
const baseLabel = original.label.replace(/ Rata \d+$/, "");
// Both halves are manual amounts by definition, so they are locked: without
// this, the next change to the project total would recompute Rata 1 from its
// (now meaningless) percent and zero out Rata 2, which carries none.
await db
.update(payments)
.set({ amount: first.toFixed(2), label: `${baseLabel} Rata 1` })
.set({ amount: first.toFixed(2), label: `${baseLabel} Rata 1`, amount_locked: true })
.where(eq(payments.id, paymentId));
// Rata 2 slots in right after Rata 1; everything below shifts down one.
await db
.update(payments)
.set({ sort_order: sql`${payments.sort_order} + 1` })
.where(
and(eq(payments.project_id, projectId), gt(payments.sort_order, original.sort_order))
);
await db.insert(payments).values({
project_id: projectId,
label: `${baseLabel} Rata 2`,
amount: second.toFixed(2),
status: original.status,
sort_order: original.sort_order + 1,
amount_locked: true,
});
revalidatePath(`/admin/projects/${projectId}`);
+121 -29
View File
@@ -1,16 +1,20 @@
"use client";
import { useState } from "react";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { RotateCcw } from "lucide-react";
import {
updatePaymentStatus,
updateAcceptedTotal,
setPaymentPaidAt,
updatePaymentField,
clearPaymentOverride,
} from "@/app/admin/clients/[id]/actions";
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EditableCell } from "@/components/ui/editable-cell";
import { SplitPaymentForm } from "@/components/admin/SplitPaymentForm";
import type { Payment } from "@/db/schema";
@@ -28,6 +32,10 @@ const statusLabels: Record<string, string> = {
saldato: "Saldato",
};
function formatEuro(value: number): string {
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
}
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
function toMonthValue(paidAt: Date | string | null | undefined): string {
if (!paidAt) {
@@ -43,7 +51,7 @@ type PlanMode = "single" | "two" | "three";
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
{ mode: "single", label: "Pagamento unico", description: "100% in un'unica soluzione" },
{ mode: "two", label: "2 step", description: "Acconto 50% + Saldo 50%" },
{ mode: "three", label: "3 step", description: "50% + 30% + 20%" },
{ mode: "three", label: "3 step", description: "50% + 25% + 25%" },
];
export function PaymentsTab({
@@ -57,35 +65,81 @@ export function PaymentsTab({
const [overrideValue, setOverrideValue] = useState(acceptedTotal);
const [planLoading, setPlanLoading] = useState<PlanMode | null>(null);
const [statusLoading, setStatusLoading] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
// The input is seeded from a prop, so it has to follow the prop after a
// router.refresh() — otherwise it keeps showing the pre-save value. Adjusted
// during render rather than in an effect (React's documented pattern for
// "resetting state when a prop changes"): no extra pass, no flash of stale value.
const [prevAcceptedTotal, setPrevAcceptedTotal] = useState(acceptedTotal);
if (prevAcceptedTotal !== acceptedTotal) {
setPrevAcceptedTotal(acceptedTotal);
setOverrideValue(acceptedTotal);
}
const currentTotal = parseFloat(acceptedTotal) || 0;
const offersTotal = offersAcceptedTotal;
const hasOffers = offersTotal > 0;
const rowsTotal = payments.reduce((sum, p) => sum + (parseFloat(p.amount) || 0), 0);
const drift = rowsTotal - currentTotal;
const hasDrift = payments.length > 0 && Math.abs(drift) >= 0.01;
// Shared runner: every mutation goes through here so a failure is visible
// instead of vanishing into an unhandled rejection.
function run(fn: () => Promise<unknown>) {
setError(null);
startTransition(async () => {
try {
await fn();
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
async function handleUseOffersTotal() {
if (!projectId) return;
setOverrideValue(offersTotal.toFixed(2));
const fd = new FormData();
fd.set("accepted_total", offersTotal.toFixed(2));
await updateAcceptedTotal(projectId, fd);
router.refresh();
run(() => updateAcceptedTotal(projectId, fd));
}
async function handleSaveTotal(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("accepted_total", overrideValue);
await updateAcceptedTotal(clientId, fd);
router.refresh();
run(() => updateAcceptedTotal(clientId, fd));
}
async function handleSetPlan(mode: PlanMode) {
if (!projectId) return;
// Picking a plan deletes every existing row — including status and paid_at.
// Only worth interrupting when there is actually payment history to lose.
const tracked = payments.filter(
(p) => p.status === "saldato" || p.status === "inviata"
).length;
if (tracked > 0) {
const what =
tracked === 1 ? "1 rata già segnata" : `${tracked} rate già segnate`;
const ok = window.confirm(
`Cambiare schema cancella tutte le rate di questo progetto.\n\nCi sono ${what} come inviate o saldate: lo stato e il mese di incasso andranno persi.\n\nProcedere?`
);
if (!ok) return;
}
setPlanLoading(mode);
setError(null);
try {
const total = parseFloat(overrideValue) || currentTotal;
await setPaymentPlan(projectId, mode, total);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
} finally {
setPlanLoading(null);
}
@@ -114,6 +168,12 @@ export function PaymentsTab({
return (
<div className="space-y-6 max-w-md">
{error && (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
{/* Totale preventivo */}
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
<h3 className="font-medium text-gray-900">Totale preventivo</h3>
@@ -124,8 +184,7 @@ export function PaymentsTab({
<span className="text-sm text-[#71717a]">
Ereditato dalle offerte attive:{" "}
<span className="font-semibold text-[#1a1a1a]">
{" "}
{offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
{formatEuro(offersTotal)}
</span>
</span>
{projectId && (
@@ -211,28 +270,48 @@ export function PaymentsTab({
{/* Payment rows */}
{payments.map((p) => {
const amount = parseFloat(p.amount);
const pct = p.percent !== null && p.percent !== undefined
? parseFloat(String(p.percent))
: null;
const pct =
p.percent !== null && p.percent !== undefined ? parseFloat(String(p.percent)) : null;
return (
<div
key={p.id}
className="bg-white border border-gray-200 rounded-lg p-4"
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-900">
<EditableCell
value={p.label}
type="text"
required
onSave={(v) => run(() => updatePaymentField(p.id, clientId, "label", v))}
/>
</div>
{pct !== null && !p.amount_locked && (
<p className="text-xs text-[#71717a] px-2">{pct}% del totale</p>
)}
{p.amount_locked && (
<p className="flex items-center gap-1 px-2 text-xs text-amber-700 dark:text-amber-500">
Importo scritto a mano
<button
type="button"
onClick={() => run(() => clearPaymentOverride(p.id, clientId))}
className="rounded p-0.5 hover:bg-amber-500/10"
aria-label="Rimuovi l'override e torna al calcolo automatico"
title="Torna al calcolo automatico dalla percentuale"
>
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="font-medium text-gray-900">{p.label}</h3>
{pct !== null && (
<p className="text-xs text-[#71717a]">{pct}% del totale</p>
<RotateCcw className="h-3 w-3" />
</button>
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">
{" "}
{amount.toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
</span>
<div className="flex items-center gap-2 shrink-0">
<div className="text-sm text-gray-600">
<EditableCell
value={p.amount}
type="number"
required
formatDisplay={(raw) => `${formatEuro(parseFloat(raw) || 0)}`}
onSave={(v) => run(() => updatePaymentField(p.id, clientId, "amount", v))}
/>
</div>
{projectId && amount > 0 && (
<SplitPaymentForm
paymentId={p.id}
@@ -258,9 +337,7 @@ export function PaymentsTab({
</option>
))}
</select>
{statusLoading === p.id && (
<span className="text-xs text-[#71717a]">...</span>
)}
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
</div>
{p.status === "saldato" && (
<div className="flex items-center gap-2 mt-2">
@@ -280,6 +357,21 @@ export function PaymentsTab({
</div>
);
})}
{/* Scarto fra la somma delle rate e il totale del progetto. Si dice, non si
sistema di nascosto: un importo scritto a mano è una scelta, non un errore. */}
{hasDrift && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10">
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
{drift > 0
? `Le rate superano il totale di € ${formatEuro(drift)}`
: `Le rate coprono € ${formatEuro(-drift)} in meno del totale`}
</p>
<p className="mt-1 font-mono text-xs tabular-nums text-amber-700 dark:text-amber-400">
Somma rate {formatEuro(rowsTotal)} · Totale {formatEuro(currentTotal)}
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,32 @@
-- Additive: give `payments` a stable display order and a manual-override flag.
--
-- Why: the table had NO ordering column at all (no sort_order, no created_at) and
-- none of the 13 queries reading it had an ORDER BY. Postgres seq-scans and returns
-- physical order; an MVCC UPDATE rewrites the tuple at the end, so marking a payment
-- "saldato" pushed it to the bottom of the list.
--
-- No drops, no truncates, no deletes — `payments` is LOCKED in CLAUDE.md.
ALTER TABLE payments ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_locked boolean NOT NULL DEFAULT false;
-- Backfill. Deliberately NOT ordered by ctid: 3 of the 5 projects in production are
-- already displaying scrambled (one shows 30/20/50, one is fully reversed 20/30/50,
-- and the legacy pair shows Saldo before Acconto), so physical order would freeze the
-- bug in place instead of fixing it.
--
-- percent DESC reconstructs the intent of every plan shape that exists (100 / 50-50 /
-- 50-30-20). The label tiebreaker puts "Acconto…" before "Saldo…" in the legacy pairs
-- that carry percent NULL.
UPDATE payments p SET sort_order = s.rn - 1
FROM (
SELECT id, row_number() OVER (
PARTITION BY project_id ORDER BY percent DESC NULLS LAST, label ASC
) AS rn
FROM payments
) s
WHERE p.id = s.id;
-- A foreign key creates no index on the referencing side, so payments had no index
-- at all beyond its PK. This one serves both the FK lookups and the new ORDER BY.
CREATE INDEX IF NOT EXISTS payments_project_sort_idx ON payments(project_id, sort_order);
+3
View File
@@ -214,6 +214,9 @@ export const payments = pgTable("payments", {
percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
paid_at: timestamp("paid_at", { withTimezone: true }),
sort_order: integer("sort_order").notNull().default(0),
// true = l'importo è stato scritto a mano: rescalePayments non lo tocca più.
amount_locked: boolean("amount_locked").notNull().default(false),
});
// ============ DOCUMENTS ============
+10 -4
View File
@@ -120,7 +120,11 @@ export async function getAllClientsWithPayments(
}
const [allPayments, activeEntries, totals, offerTotals] = await Promise.all([
db.select().from(payments).where(inArray(payments.project_id, projectIds)),
db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds))
.orderBy(asc(payments.sort_order)),
db
.select({
@@ -326,7 +330,8 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
const paymentsRows = await db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds));
.where(inArray(payments.project_id, projectIds))
.orderBy(asc(payments.sort_order));
const documentsRows = await db
.select()
@@ -510,7 +515,8 @@ export async function getAllProjectsWithPayments(
db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds)),
.where(inArray(payments.project_id, projectIds))
.orderBy(asc(payments.sort_order)),
db
.select({
@@ -641,7 +647,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, taskSecondsRows, phaseSecondsRows, projectOffersRows, availableMicrosRows, transcriptsRows] =
await Promise.all([
db.select().from(payments).where(eq(payments.project_id, id)),
db.select().from(payments).where(eq(payments.project_id, id)).orderBy(asc(payments.sort_order)),
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)),
db
+2 -1
View File
@@ -319,7 +319,8 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
// amount intentionally excluded — client API never exposes payment amounts
})
.from(payments)
.where(eq(payments.project_id, projectId));
.where(eq(payments.project_id, projectId))
.orderBy(asc(payments.sort_order));
const documentsRows = await db
.select({