diff --git a/app/(protected)/invoices/page.tsx b/app/(protected)/invoices/page.tsx index 3978fb9..5b452a4 100644 --- a/app/(protected)/invoices/page.tsx +++ b/app/(protected)/invoices/page.tsx @@ -58,11 +58,15 @@ export default function InvoicesPage() { const bMap = new Map(bidders.map((b) => [b.id!, b])); return invs .map((inv) => ({ ...inv, bidder: bMap.get(inv.bidderId) })) - .sort( - (a, b) => - new Date(b.generatedAt).getTime() - - new Date(a.generatedAt).getTime() - ); + .sort((a, b) => { + // Sort by invoice number (ascending) for a stable order that + // doesn't reshuffle on every recalc/sync. Falls back to id. + const an = a.invoiceNumber ?? ""; + const bn = b.invoiceNumber ?? ""; + const cmp = an.localeCompare(bn, undefined, { numeric: true }); + if (cmp !== 0) return cmp; + return (a.id ?? 0) - (b.id ?? 0); + }); }, []), [currentEventId, dbReady, db] ); diff --git a/components/bidders/BidderForm.tsx b/components/bidders/BidderForm.tsx index 3807ba5..7f98985 100644 --- a/components/bidders/BidderForm.tsx +++ b/components/bidders/BidderForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { Bidder } from "@/lib/db"; import { useUserDb } from "@/components/providers/UserDbProvider"; import { useCloudSync } from "@/components/providers/CloudSyncProvider"; @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { getSuggestedPaddleNumber } from "@/lib/hooks/useBidders"; import { mutateWithParentEventTouch } from "@/lib/db/mutateWithParentEventTouch"; +import { flushSingleEventToCloudSnapshot } from "@/lib/services/cloudSync"; type Props = { open: boolean; @@ -33,18 +34,26 @@ export function BidderForm({ const [phone, setPhone] = useState(""); const [email, setEmail] = useState(""); const [error, setError] = useState(null); + const [paddleReady, setPaddleReady] = useState(false); + const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); useEffect(() => { if (!open) return; setError(null); + setPaddleReady(false); (async () => { - if (!db) return; + if (!db) { + setError("Local database is unavailable. Reload and try again."); + return; + } if (editing) { setPaddleNumber(String(editing.paddleNumber)); setFirstName(editing.firstName); setLastName(editing.lastName); setPhone(editing.phone ?? ""); setEmail(editing.email ?? ""); + setPaddleReady(true); } else { const next = await getSuggestedPaddleNumber(db, eventId); setPaddleNumber(String(next)); @@ -52,13 +61,22 @@ export function BidderForm({ setLastName(""); setPhone(""); setEmail(""); + setPaddleReady(true); } })(); }, [open, editing, eventId, db]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!db) return; + if (submittingRef.current) return; + if (!db) { + setError("Local database is unavailable. Reload and try again."); + return; + } + if (!paddleReady) { + setError("One moment — still loading. Try again."); + return; + } setError(null); const paddle = parseInt(paddleNumber.trim(), 10); if (!Number.isFinite(paddle) || paddle < 1) { @@ -71,52 +89,70 @@ export function BidderForm({ setError("First and last name are required."); return; } - const taken = await db.bidders - .where("[eventId+paddleNumber]") - .equals([eventId, paddle]) - .first(); - const editingId = editing?.id; - if ( - taken != null && - (typeof editingId !== "number" || taken.id !== editingId) - ) { - setError(`Paddle #${paddle} is already registered for this event.`); - return; - } - const now = new Date(); + submittingRef.current = true; + setSubmitting(true); try { - await mutateWithParentEventTouch(db, eventId, "bidders", async () => { - if (editing?.id != null) { - await db.bidders.update(editing.id, { - paddleNumber: paddle, - firstName: fn, - lastName: ln, - phone: phone.trim() || undefined, - email: email.trim() || undefined, - updatedAt: now, - }); - } else { - await db.bidders.add({ - eventId, - paddleNumber: paddle, - firstName: fn, - lastName: ln, - phone: phone.trim() || undefined, - email: email.trim() || undefined, - createdAt: now, - updatedAt: now, - }); + const taken = await db.bidders + .where("[eventId+paddleNumber]") + .equals([eventId, paddle]) + .first(); + const editingId = editing?.id; + if ( + taken != null && + (typeof editingId !== "number" || taken.id !== editingId) + ) { + setError(`Paddle #${paddle} is already registered for this event.`); + return; + } + const now = new Date(); + try { + await mutateWithParentEventTouch(db, eventId, "bidders", async () => { + if (editing?.id != null) { + await db.bidders.update(editing.id, { + paddleNumber: paddle, + firstName: fn, + lastName: ln, + phone: phone.trim() || undefined, + email: email.trim() || undefined, + updatedAt: now, + }); + } else { + await db.bidders.add({ + eventId, + paddleNumber: paddle, + firstName: fn, + lastName: ln, + phone: phone.trim() || undefined, + email: email.trim() || undefined, + createdAt: now, + updatedAt: now, + }); + } + }); + } catch (err) { + setError( + err instanceof Error ? err.message : "Could not save bidder. Try again." + ); + return; + } + // Bidder ops are not tracked in the per-event op log, so a background + // pull can full-replace local bidders before the debounced cloud push + // runs. Flush a snapshot immediately when online so the new/edited + // bidder is durable on the server before the next pull. + if (typeof navigator !== "undefined" && navigator.onLine) { + try { + await flushSingleEventToCloudSnapshot(db, eventId); + } catch { + /* fall back to debounced push */ } - }); - } catch (e) { - setError( - e instanceof Error ? e.message : "Could not save bidder. Try again." - ); - return; + } + scheduleCloudPush(); + onSaved(); + onClose(); + } finally { + submittingRef.current = false; + setSubmitting(false); } - scheduleCloudPush(); - onSaved(); - onClose(); } return ( @@ -126,11 +162,24 @@ export function BidderForm({ onClose={onClose} footer={ <> - - } diff --git a/components/clerking/SaleForm.tsx b/components/clerking/SaleForm.tsx index 5beb73f..0b58860 100644 --- a/components/clerking/SaleForm.tsx +++ b/components/clerking/SaleForm.tsx @@ -30,13 +30,16 @@ import { PassOutCheckbox } from "./PassOutCheckbox"; import { useToast } from "@/components/providers/ToastProvider"; import { useCloudSync } from "@/components/providers/CloudSyncProvider"; import { formatCurrency } from "@/lib/utils/formatCurrency"; -import { roundMoney } from "@/lib/services/invoiceLogic"; +import { roundMoney, upsertInvoiceForBidder } from "@/lib/services/invoiceLogic"; import { mutateWithEventTables } from "@/lib/db/mutateWithParentEventTouch"; import { newEntitySyncKey } from "@/lib/utils/clientSyncKey"; import { enqueueSalePut } from "@/lib/sync/ops/enqueueOps"; import { + readStickyConsignor, readSuggestNextLot, + subscribeStickyConsignor, subscribeSuggestNextLot, + writeStickyConsignor, writeSuggestNextLot, } from "@/lib/clerkFormPrefs"; import { @@ -105,6 +108,9 @@ export function SaleForm({ const [clerkInitials, setClerkInitials] = useState(""); const [passOutEnabled, setPassOutEnabled] = useState(false); const [formError, setFormError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); + const lastSaleSignatureRef = useRef<{ key: string; at: number } | null>(null); const fieldRefs = useRef>>( {} @@ -127,6 +133,12 @@ export function SaleForm({ () => true ); + const stickyConsignorEnabled = useSyncExternalStore( + subscribeStickyConsignor, + readStickyConsignor, + () => true + ); + const consignors = useLiveQuery( async () => liveQueryGuard("saleForm.consignors", async () => { @@ -342,8 +354,10 @@ export function SaleForm({ setPassOutEnabled(false); setTitle(""); - setConsignor(""); - setLinkedConsignorId(null); + if (!readStickyConsignor()) { + setConsignor(""); + setLinkedConsignorId(null); + } setLotNotes(""); setQuantity("1"); setSellPrice(""); @@ -354,6 +368,19 @@ export function SaleForm({ } async function submitSale(shiftEnter: boolean) { + if (submittingRef.current) return; + if (!db) return; + submittingRef.current = true; + setSubmitting(true); + try { + await submitSaleInner(shiftEnter); + } finally { + submittingRef.current = false; + setSubmitting(false); + } + } + + async function submitSaleInner(shiftEnter: boolean) { if (!db) return; setFormError(null); const passOutActive = passOutEnabled || shiftEnter; @@ -510,6 +537,22 @@ export function SaleForm({ const lineHammer = roundMoney(unitHammer * qty); + // Defensive guard against accidental re-submits (e.g. double-press, key + // repeat, sticky Enter): if the same (lot/paddle/amount) was just + // recorded for this event in the last 1.5s, refuse to write a duplicate. + const dupSignature = `${eventId}|${displayStr}|${baseNum}|${newSuffix}|${paddle}|${lineHammer}`; + const dupNow = Date.now(); + if ( + lastSaleSignatureRef.current != null && + lastSaleSignatureRef.current.key === dupSignature && + dupNow - lastSaleSignatureRef.current.at < 1500 + ) { + setFormError( + "Same sale just recorded — clear the form or change a value to record again." + ); + return; + } + function buildSaleRow(lotId: number, saleSyncKey: string): Omit { const row: Omit = { eventId, @@ -536,6 +579,25 @@ export function SaleForm({ let newLotId = 0; let newSaleId = 0; await mutateWithEventTables(db, eventId, [db.lots, db.sales], async () => { + // Re-check inside the transaction: another tab/device may have + // created this lot or recorded a sale for it after our outer read. + const racedLot = await findLotByEventBaseAndSuffix( + db, + eventId, + baseNum, + newSuffix + ); + if (racedLot?.id != null) { + const racedSales = await db.sales + .where("lotId") + .equals(racedLot.id) + .count(); + if (racedSales > 0) { + throw new Error( + "Lot was just sold by another device — refresh and try again." + ); + } + } const newLotRow: Omit = { eventId, baseLotNumber: baseNum, @@ -581,6 +643,17 @@ export function SaleForm({ if (openCatalog) { let newSaleId = 0; await mutateWithEventTables(db, eventId, [db.lots, db.sales], async () => { + // Re-check inside the transaction in case another tab/device + // recorded a sale on this lot between our outer read and now. + const existingCount = await db.sales + .where("lotId") + .equals(lotId) + .count(); + if (existingCount > 0) { + throw new Error( + "Lot was just sold by another device — refresh and try again." + ); + } await patchLotWithConsignor( db, lotId, @@ -616,6 +689,15 @@ export function SaleForm({ } else { let newSaleId = 0; await mutateWithEventTables(db, eventId, [db.lots, db.sales], async () => { + const existingCount = await db.sales + .where("lotId") + .equals(lotId) + .count(); + if (existingCount > 0) { + throw new Error( + "Lot was just sold by another device — refresh and try again." + ); + } await patchLotWithConsignor( db, lotId, @@ -636,6 +718,8 @@ export function SaleForm({ return; } + lastSaleSignatureRef.current = { key: dupSignature, at: Date.now() }; + try { sessionStorage.setItem(CLERK_KEY, initials); } catch { @@ -648,6 +732,19 @@ export function SaleForm({ if (s) await enqueueSalePut(db, evCloud.syncId, s); } + // Auto-allocate this sale onto the bidder's open invoice (or create + // a new supplemental invoice if all prior invoices are paid). This + // eliminates the "items not on the invoice" gap where sales stayed + // unallocated until someone manually clicked "Generate". + try { + const evForInvoice = await db.events.get(eventId); + if (evForInvoice) { + await upsertInvoiceForBidder(db, evForInvoice, bidderId); + } + } catch { + // Sale write succeeded; allocation can be retried via Generate. + } + showToast({ kind: "success", message: `Sale recorded — ${displayStr}` }); scheduleCloudPush(); @@ -660,8 +757,13 @@ export function SaleForm({ } else { setPassOutEnabled(false); setTitle(""); - setConsignor(""); - setLinkedConsignorId(null); + // Sticky consignor: leave consignor + linkedConsignorId set so the + // clerk doesn't have to re-pick it for the next lot. Lot autofill + // still overwrites it when a looked-up lot has a different consignor. + if (!readStickyConsignor()) { + setConsignor(""); + setLinkedConsignorId(null); + } setLotNotes(""); setQuantity("1"); setSellPrice(""); @@ -988,6 +1090,20 @@ export function SaleForm({ + + - Record sale + {submitting ? "Recording…" : "Record sale"}

Same as pressing Enter — fill fields marked required in{" "} @@ -1011,7 +1128,12 @@ export function SaleForm({

-

diff --git a/components/invoices/InvoiceDetail.tsx b/components/invoices/InvoiceDetail.tsx index a2dda0c..e31850f 100644 --- a/components/invoices/InvoiceDetail.tsx +++ b/components/invoices/InvoiceDetail.tsx @@ -230,6 +230,23 @@ export function InvoiceDetailModal({ inv.buyersPremiumAmount !== 0 || bpEff > 0; const manualLines = inv.manualLines ?? []; + // Defensive de-duplication by lotId (legacy bug + multi-device race + // condition could leave two sale rows attached to the same lot on one + // invoice). Keep the most recent (highest id wins as a stable tiebreaker) + // so the user sees one line per lot. The underlying sales rows are not + // modified here; the Reports → repair pass cleans them up. + const dedupedSalesMap = new Map(); + for (const s of sales) { + const existing = dedupedSalesMap.get(s.lotId); + if ( + !existing || + (s.id != null && existing.id != null && s.id > existing.id) + ) { + dedupedSalesMap.set(s.lotId, s); + } + } + const displaySales: Sale[] = Array.from(dedupedSalesMap.values()); + async function setManualLines(next: InvoiceManualLine[]) { await persistAndRecalc({ manualLines: next }); } @@ -554,7 +571,7 @@ export function InvoiceDetailModal({ - {sales.length === 0 && manualLines.length === 0 ? ( + {displaySales.length === 0 && manualLines.length === 0 ? ( ) : null} - {sales.map((s) => ( + {displaySales.map((s) => ( {s.displayLotNumber} {s.description} diff --git a/components/invoices/PaymentModal.tsx b/components/invoices/PaymentModal.tsx index 949b398..34c6e56 100644 --- a/components/invoices/PaymentModal.tsx +++ b/components/invoices/PaymentModal.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import type { Invoice } from "@/lib/db"; import { mutateWithEventTables } from "@/lib/db/mutateWithParentEventTouch"; import { useUserDb } from "@/components/providers/UserDbProvider"; import { useCloudSync } from "@/components/providers/CloudSyncProvider"; -import { enqueueInvoicePut } from "@/lib/sync/ops/enqueueOps"; +import { enqueueInvoicePatch } from "@/lib/sync/ops/enqueueOps"; import { Modal } from "@/components/ui/Modal"; import { Button } from "@/components/ui/Button"; import { PAYMENT_METHODS } from "@/lib/utils/constants"; @@ -24,28 +24,57 @@ export function PaymentModal({ const { db } = useUserDb(); const { scheduleCloudPush } = useCloudSync(); const [method, setMethod] = useState("cash"); + const [confirming, setConfirming] = useState(false); + const confirmingRef = useRef(false); useEffect(() => { - if (open) setMethod("cash"); + if (open) { + setMethod("cash"); + setConfirming(false); + confirmingRef.current = false; + } }, [open, invoice?.id]); async function confirm() { + if (confirmingRef.current) return; if (invoice?.id == null || !db) return; - await mutateWithEventTables(db, invoice.eventId, [db.invoices], async () => { - await db.invoices.update(invoice.id, { - status: "paid", - paymentMethod: method as "cash" | "check" | "credit_card" | "other", - paymentDate: new Date(), - }); - }); - const inv = await db.invoices.get(invoice.id); - const ev = inv ? await db.events.get(inv.eventId) : null; - if (inv?.id != null && ev?.syncId) { - await enqueueInvoicePut(db, ev.syncId, inv.id); + confirmingRef.current = true; + setConfirming(true); + try { + const now = new Date(); + const pm = method as "cash" | "check" | "credit_card" | "other"; + await mutateWithEventTables( + db, + invoice.eventId, + [db.invoices], + async () => { + await db.invoices.update(invoice.id, { + status: "paid", + paymentMethod: pm, + paymentDate: now, + // Bump generatedAt so this paid edit wins last-write-wins + // against a teammate's later recalc that left it unpaid. + generatedAt: now, + }); + } + ); + const inv = await db.invoices.get(invoice.id); + const ev = inv ? await db.events.get(inv.eventId) : null; + if (inv?.id != null && ev?.syncId) { + await enqueueInvoicePatch(db, ev.syncId, inv.id, { + status: "paid", + paymentMethod: pm, + paymentDate: now, + generatedAt: now, + }); + } + scheduleCloudPush(); + onPaid(invoice); + onClose(); + } finally { + confirmingRef.current = false; + setConfirming(false); } - scheduleCloudPush(); - onPaid(invoice); - onClose(); } return ( @@ -55,11 +84,20 @@ export function PaymentModal({ onClose={onClose} footer={ <> - - } diff --git a/components/providers/UserDbProvider.tsx b/components/providers/UserDbProvider.tsx index f1a296f..8a30ac1 100644 --- a/components/providers/UserDbProvider.tsx +++ b/components/providers/UserDbProvider.tsx @@ -16,6 +16,7 @@ import { } from "@/lib/db"; import { ensureSettingsRow } from "@/lib/settings"; import { readOfflineUserId } from "@/lib/auth/offlineSession"; +import { repairDuplicateInvoiceLines } from "@/lib/db/repairDuplicateInvoiceLines"; export type UserDbContextValue = { db: AuctionDB | null; @@ -69,6 +70,9 @@ export function UserDbProvider({ children }: { children: ReactNode }) { try { await migrateLegacyToUserDb(db); await ensureSettingsRow(db); + // One-shot defensive repair for legacy duplicate invoice lines. + // Gated by a localStorage flag so it runs at most once per profile. + await repairDuplicateInvoiceLines(db); } catch (e) { console.error(e); } finally { diff --git a/lib/clerkFormPrefs.test.ts b/lib/clerkFormPrefs.test.ts new file mode 100644 index 0000000..469c50e --- /dev/null +++ b/lib/clerkFormPrefs.test.ts @@ -0,0 +1,61 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it } from "vitest"; +import { + readStickyConsignor, + readSuggestNextLot, + writeStickyConsignor, + writeSuggestNextLot, +} from "./clerkFormPrefs"; + +beforeEach(() => { + try { + window.localStorage.clear(); + } catch { + /* ignore */ + } +}); + +describe("readSuggestNextLot / writeSuggestNextLot", () => { + it("defaults to true", () => { + expect(readSuggestNextLot()).toBe(true); + }); + + it("persists false and reads it back", () => { + writeSuggestNextLot(false); + expect(readSuggestNextLot()).toBe(false); + }); + + it("clears the override when set back to true", () => { + writeSuggestNextLot(false); + writeSuggestNextLot(true); + expect(readSuggestNextLot()).toBe(true); + }); +}); + +describe("readStickyConsignor / writeStickyConsignor", () => { + it("defaults to true (sticky)", () => { + expect(readStickyConsignor()).toBe(true); + }); + + it("persists false (legacy reset behavior)", () => { + writeStickyConsignor(false); + expect(readStickyConsignor()).toBe(false); + }); + + it("clears the override when set back to true", () => { + writeStickyConsignor(false); + writeStickyConsignor(true); + expect(readStickyConsignor()).toBe(true); + }); + + it("is independent of suggestNextLot", () => { + writeStickyConsignor(false); + writeSuggestNextLot(false); + expect(readStickyConsignor()).toBe(false); + expect(readSuggestNextLot()).toBe(false); + + writeStickyConsignor(true); + expect(readStickyConsignor()).toBe(true); + expect(readSuggestNextLot()).toBe(false); + }); +}); diff --git a/lib/clerkFormPrefs.ts b/lib/clerkFormPrefs.ts index fda3315..7fe681a 100644 --- a/lib/clerkFormPrefs.ts +++ b/lib/clerkFormPrefs.ts @@ -1,7 +1,10 @@ const SUGGEST_NEXT_LOT_KEY = "clerkbid:suggestNextLot"; +const STICKY_CONSIGNOR_KEY = "clerkbid:stickyConsignor"; /** Dispatched after writeSuggestNextLot (storage from other tabs also fires storage). */ export const SUGGEST_NEXT_LOT_CHANGED = "clerkbid:suggestNextLotChanged"; +/** Dispatched after writeStickyConsignor. */ +export const STICKY_CONSIGNOR_CHANGED = "clerkbid:stickyConsignorChanged"; /** When true (default), clerking pre-fills the next sequential lot after reset or sale. */ export function readSuggestNextLot(): boolean { @@ -37,3 +40,45 @@ export function subscribeSuggestNextLot(onStoreChange: () => void): () => void { } return () => {}; } + +/** + * When true (default), the consignor selection persists across consecutive + * sales. Clerks must explicitly clear or change it; lot autofill still + * overwrites when the looked-up lot has a different consignor. Set to false + * to restore the legacy behavior of clearing consignor after every sale. + */ +export function readStickyConsignor(): boolean { + if (typeof window === "undefined") return true; + try { + return localStorage.getItem(STICKY_CONSIGNOR_KEY) !== "0"; + } catch { + return true; + } +} + +export function writeStickyConsignor(sticky: boolean): void { + try { + if (sticky) localStorage.removeItem(STICKY_CONSIGNOR_KEY); + else localStorage.setItem(STICKY_CONSIGNOR_KEY, "0"); + } catch { + /* ignore */ + } + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(STICKY_CONSIGNOR_CHANGED)); + } +} + +export function subscribeStickyConsignor( + onStoreChange: () => void +): () => void { + const fn = () => onStoreChange(); + if (typeof window !== "undefined") { + window.addEventListener(STICKY_CONSIGNOR_CHANGED, fn); + window.addEventListener("storage", fn); + return () => { + window.removeEventListener(STICKY_CONSIGNOR_CHANGED, fn); + window.removeEventListener("storage", fn); + }; + } + return () => {}; +} diff --git a/lib/db/repairDuplicateInvoiceLines.test.ts b/lib/db/repairDuplicateInvoiceLines.test.ts new file mode 100644 index 0000000..e61e75e --- /dev/null +++ b/lib/db/repairDuplicateInvoiceLines.test.ts @@ -0,0 +1,214 @@ +/** @vitest-environment jsdom */ +import "fake-indexeddb/auto"; +import Dexie from "dexie"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuctionDB } from "@/lib/db"; +import { repairDuplicateInvoiceLines } from "@/lib/db/repairDuplicateInvoiceLines"; + +let db: AuctionDB; +let eventId: number; +let bidderId: number; + +const REPAIR_FLAG_KEY = "clerkbid:duplicateInvoiceLinesRepaired:v1"; + +beforeEach(async () => { + // The repair pass is gated by a localStorage flag; clear it so each + // test starts fresh. + try { + window.localStorage.removeItem(REPAIR_FLAG_KEY); + } catch { + /* ignore */ + } + const uid = `repair_${Date.now()}_${Math.random().toString(36).slice(2)}`; + db = new AuctionDB(uid); + eventId = (await db.events.add({ + name: "E", + organizationName: "O", + taxRate: 0, + buyersPremiumRate: 0, + defaultConsignorCommissionRate: 0, + currencySymbol: "$", + syncId: "evt-repair", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + bidderId = (await db.bidders.add({ + eventId, + paddleNumber: 1, + firstName: "B", + lastName: "B", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; +}); + +afterEach(async () => { + db.close(); + await Dexie.delete(db.name); +}); + +describe("repairDuplicateInvoiceLines", () => { + it("detaches older duplicate sale rows for the same lot on one invoice", async () => { + const lotId = (await db.lots.add({ + eventId, + baseLotNumber: 1, + lotSuffix: "", + displayLotNumber: "1", + description: "Lot 1", + quantity: 1, + status: "sold", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + const invId = (await db.invoices.add({ + eventId, + bidderId, + invoiceNumber: "1-001", + subtotal: 0, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 0, + status: "unpaid", + generatedAt: new Date(), + })) as number; + const oldSaleId = (await db.sales.add({ + eventId, + lotId, + bidderId, + displayLotNumber: "1", + paddleNumber: 1, + description: "Lot 1", + quantity: 1, + amount: 50, + clerkInitials: "AB", + createdAt: new Date(), + invoiceId: invId, + })) as number; + const newSaleId = (await db.sales.add({ + eventId, + lotId, + bidderId, + displayLotNumber: "1", + paddleNumber: 1, + description: "Lot 1 (corrected)", + quantity: 1, + amount: 60, + clerkInitials: "AB", + createdAt: new Date(), + invoiceId: invId, + })) as number; + + await repairDuplicateInvoiceLines(db); + + const oldSale = await db.sales.get(oldSaleId); + const newSale = await db.sales.get(newSaleId); + // Newer sale stays attached, older sale is detached (kept as + // unallocated for re-attach via Generate). + expect(newSale?.invoiceId).toBe(invId); + expect(oldSale?.invoiceId).toBeUndefined(); + }); + + it("is idempotent — second run does nothing because flag is set", async () => { + const lotId = (await db.lots.add({ + eventId, + baseLotNumber: 1, + lotSuffix: "", + displayLotNumber: "1", + description: "Lot 1", + quantity: 1, + status: "sold", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + const invId = (await db.invoices.add({ + eventId, + bidderId, + invoiceNumber: "1-001", + subtotal: 0, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 0, + status: "unpaid", + generatedAt: new Date(), + })) as number; + await db.sales.add({ + eventId, + lotId, + bidderId, + displayLotNumber: "1", + paddleNumber: 1, + description: "Lot 1", + quantity: 1, + amount: 50, + clerkInitials: "AB", + createdAt: new Date(), + invoiceId: invId, + }); + + await repairDuplicateInvoiceLines(db); + + // Now fabricate a brand new duplicate after the repair flag is set: + // the second call should NOT touch it. + const dupId = (await db.sales.add({ + eventId, + lotId, + bidderId, + displayLotNumber: "1", + paddleNumber: 1, + description: "Lot 1 dup", + quantity: 1, + amount: 50, + clerkInitials: "AB", + createdAt: new Date(), + invoiceId: invId, + })) as number; + + await repairDuplicateInvoiceLines(db); + + const dup = await db.sales.get(dupId); + expect(dup?.invoiceId).toBe(invId); + }); + + it("leaves single-line invoices alone", async () => { + const lotId = (await db.lots.add({ + eventId, + baseLotNumber: 2, + lotSuffix: "", + displayLotNumber: "2", + description: "Lot 2", + quantity: 1, + status: "sold", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + const invId = (await db.invoices.add({ + eventId, + bidderId, + invoiceNumber: "1-002", + subtotal: 75, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 75, + status: "unpaid", + generatedAt: new Date(), + })) as number; + const saleId = (await db.sales.add({ + eventId, + lotId, + bidderId, + displayLotNumber: "2", + paddleNumber: 1, + description: "Lot 2", + quantity: 1, + amount: 75, + clerkInitials: "AB", + createdAt: new Date(), + invoiceId: invId, + })) as number; + + await repairDuplicateInvoiceLines(db); + + const sale = await db.sales.get(saleId); + expect(sale?.invoiceId).toBe(invId); + }); +}); diff --git a/lib/db/repairDuplicateInvoiceLines.ts b/lib/db/repairDuplicateInvoiceLines.ts new file mode 100644 index 0000000..cf95991 --- /dev/null +++ b/lib/db/repairDuplicateInvoiceLines.ts @@ -0,0 +1,69 @@ +import type { AuctionDB, Sale } from "@/lib/db"; + +const REPAIR_FLAG_KEY = "clerkbid:duplicateInvoiceLinesRepaired:v1"; + +/** + * One-shot data-repair pass that finds invoices where the same `lotId` + * appears more than once across attached sales and detaches the older + * duplicate(s). The keeper is the highest-id sale for that lot (most + * recent write). Detached sales become unallocated and can be re-attached + * via the next "Generate" pass; we do not delete them. + * + * The legacy DB v6 migration assigned every unallocated sale for a bidder + * to every invoice for that bidder in id order, which could leave the same + * lot duplicated on a single invoice. Multi-device generate races could + * also produce this state. The repair is gated behind a localStorage flag + * so it only runs once per browser profile. + */ +export async function repairDuplicateInvoiceLines( + db: AuctionDB +): Promise { + if (typeof window === "undefined") return; + try { + if (window.localStorage.getItem(REPAIR_FLAG_KEY) === "1") return; + } catch { + return; + } + + try { + const sales = await db.sales.toArray(); + // Group by composite "invoiceId|lotId" so we can find duplicates + // attached to the same invoice for the same lot. + const groups: Record = {}; + for (const s of sales) { + if (s.invoiceId == null) continue; + const key = `${s.invoiceId}|${s.lotId}`; + if (!groups[key]) groups[key] = []; + groups[key].push(s); + } + + const detachIds: number[] = []; + for (const key of Object.keys(groups)) { + const lotSales = groups[key]; + if (!lotSales || lotSales.length < 2) continue; + // Keep the most recent (highest id); detach the rest. + lotSales.sort((a: Sale, b: Sale) => (b.id ?? 0) - (a.id ?? 0)); + for (let i = 1; i < lotSales.length; i++) { + const sId = lotSales[i]?.id; + if (sId != null) detachIds.push(sId); + } + } + + if (detachIds.length > 0) { + await db.transaction("rw", db.sales, async () => { + for (const id of detachIds) { + await db.sales.update(id, { invoiceId: undefined }); + } + }); + } + } catch (e) { + console.error("repairDuplicateInvoiceLines failed", e); + return; + } + + try { + window.localStorage.setItem(REPAIR_FLAG_KEY, "1"); + } catch { + /* ignore */ + } +} diff --git a/lib/services/invoiceLogic.test.ts b/lib/services/invoiceLogic.test.ts index d8ef151..31a2977 100644 --- a/lib/services/invoiceLogic.test.ts +++ b/lib/services/invoiceLogic.test.ts @@ -8,8 +8,10 @@ import { effectiveInvoiceBuyersPremiumRate, effectiveInvoiceTaxRate, formatInvoiceNumber, + recalculateAndPersistInvoice, resolveInvoiceForOpenDetail, roundMoney, + upsertInvoiceForBidder, } from "./invoiceLogic"; describe("roundMoney", () => { @@ -194,3 +196,221 @@ describe("resolveInvoiceForOpenDetail", () => { expect(found?.id).toBe(newId); }); }); + +describe("recalculateAndPersistInvoice no-op skip", () => { + let db: AuctionDB; + let eventId: number; + let bidderId: number; + let event: AuctionEvent; + + beforeEach(async () => { + const uid = `recalc_${Date.now()}_${Math.random().toString(36).slice(2)}`; + db = new AuctionDB(uid); + eventId = (await db.events.add({ + name: "E", + organizationName: "O", + taxRate: 0, + buyersPremiumRate: 0, + defaultConsignorCommissionRate: 0, + currencySymbol: "$", + syncId: "evt-recalc-1", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + event = (await db.events.get(eventId)) as AuctionEvent; + bidderId = (await db.bidders.add({ + eventId, + paddleNumber: 7, + firstName: "R", + lastName: "S", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + }); + + afterEach(async () => { + db.close(); + await Dexie.delete(db.name); + }); + + it("does not update invoice row when totals are unchanged and touchGeneratedAt is false", async () => { + const generatedAt = new Date("2026-01-01T00:00:00.000Z"); + const invId = (await db.invoices.add({ + eventId, + bidderId, + invoiceNumber: "1-001", + subtotal: 0, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 0, + status: "unpaid", + generatedAt, + })) as number; + const eventBefore = await db.events.get(eventId); + + await recalculateAndPersistInvoice(db, invId, event); + + const inv = await db.invoices.get(invId); + expect(inv?.generatedAt).toEqual(generatedAt); + // events.updatedAt should not have been bumped, since the row was not + // touched at all (live queries should not have refired). + const eventAfter = await db.events.get(eventId); + expect(eventAfter?.updatedAt?.getTime()).toBe( + eventBefore?.updatedAt?.getTime() + ); + }); + + it("bumps generatedAt when touchGeneratedAt=true even with unchanged totals", async () => { + const generatedAt = new Date("2026-01-01T00:00:00.000Z"); + const invId = (await db.invoices.add({ + eventId, + bidderId, + invoiceNumber: "1-001", + subtotal: 0, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 0, + status: "unpaid", + generatedAt, + })) as number; + + await recalculateAndPersistInvoice(db, invId, event, { + touchGeneratedAt: true, + }); + + const inv = await db.invoices.get(invId); + expect(inv?.generatedAt?.getTime()).toBeGreaterThan( + generatedAt.getTime() + ); + }); +}); + +describe("upsertInvoiceForBidder no-op recalc", () => { + let db: AuctionDB; + let eventId: number; + let bidderId: number; + let event: AuctionEvent; + + beforeEach(async () => { + const uid = `upsert_${Date.now()}_${Math.random().toString(36).slice(2)}`; + db = new AuctionDB(uid); + eventId = (await db.events.add({ + name: "E", + organizationName: "O", + taxRate: 0, + buyersPremiumRate: 0, + defaultConsignorCommissionRate: 0, + currencySymbol: "$", + syncId: "evt-upsert-1", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + event = (await db.events.get(eventId)) as AuctionEvent; + bidderId = (await db.bidders.add({ + eventId, + paddleNumber: 9, + firstName: "U", + lastName: "P", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + }); + + afterEach(async () => { + db.close(); + await Dexie.delete(db.name); + }); + + it("does not bump generatedAt when no sales are unallocated and totals match", async () => { + const lotId = (await db.lots.add({ + eventId, + baseLotNumber: 1, + lotSuffix: "", + displayLotNumber: "1", + description: "Lot 1", + quantity: 1, + status: "sold", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + const generatedAt = new Date("2026-01-01T00:00:00.000Z"); + const invId = (await db.invoices.add({ + eventId, + bidderId, + invoiceNumber: "1-001", + subtotal: 100, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 100, + status: "unpaid", + generatedAt, + })) as number; + await db.sales.add({ + eventId, + lotId, + bidderId, + displayLotNumber: "1", + paddleNumber: 9, + description: "Lot 1", + quantity: 1, + amount: 100, + clerkInitials: "AB", + createdAt: new Date(), + invoiceId: invId, + }); + + await upsertInvoiceForBidder(db, event, bidderId); + + const inv = await db.invoices.get(invId); + // generatedAt should be untouched: nothing was allocated and totals match. + expect(inv?.generatedAt).toEqual(generatedAt); + }); + + it("bumps generatedAt when an unallocated sale is attached", async () => { + const lotId = (await db.lots.add({ + eventId, + baseLotNumber: 2, + lotSuffix: "", + displayLotNumber: "2", + description: "Lot 2", + quantity: 1, + status: "sold", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + const generatedAt = new Date("2026-01-01T00:00:00.000Z"); + const invId = (await db.invoices.add({ + eventId, + bidderId, + invoiceNumber: "1-001", + subtotal: 0, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 0, + status: "unpaid", + generatedAt, + })) as number; + await db.sales.add({ + eventId, + lotId, + bidderId, + displayLotNumber: "2", + paddleNumber: 9, + description: "Lot 2", + quantity: 1, + amount: 50, + clerkInitials: "AB", + createdAt: new Date(), + // invoiceId omitted — unallocated + }); + + await upsertInvoiceForBidder(db, event, bidderId); + + const inv = await db.invoices.get(invId); + expect(inv?.generatedAt?.getTime()).toBeGreaterThan( + generatedAt.getTime() + ); + expect(inv?.subtotal).toBe(50); + expect(inv?.total).toBe(50); + }); +}); diff --git a/lib/services/invoiceLogic.ts b/lib/services/invoiceLogic.ts index 3f9be06..1c6562d 100644 --- a/lib/services/invoiceLogic.ts +++ b/lib/services/invoiceLogic.ts @@ -153,7 +153,9 @@ export type UpsertResult = /** * Recompute persisted totals from sales + manual lines + effective rates. - * No-op for paid invoices. + * No-op for paid invoices. If computed totals match the stored row and + * `touchGeneratedAt` is not requested, the row is left untouched to avoid + * spurious live-query refires that reshuffle/jitter the invoices list. */ export async function recalculateAndPersistInvoice( db: AuctionDB, @@ -174,6 +176,12 @@ export async function recalculateAndPersistInvoice( inv, event ); + const totalsUnchanged = + parts.subtotal === inv.subtotal && + parts.buyersPremiumAmount === inv.buyersPremiumAmount && + parts.taxAmount === inv.taxAmount && + parts.total === inv.total; + if (totalsUnchanged && !options?.touchGeneratedAt) return; await db.transaction("rw", [db.events, db.invoices], async () => { await db.events.update(inv.eventId, { updatedAt: new Date() }); await db.invoices.update(invoiceId, { @@ -220,19 +228,64 @@ export async function upsertInvoiceForBidder( const now = new Date(); if (unpaid?.id != null) { - if (unallocated.length > 0) { - await db.transaction("rw", [db.events, db.sales], async () => { + // No-op skip: nothing to allocate AND totals already match — avoid + // touching the row so live queries don't refire and reorder the list. + if (unallocated.length === 0) { + const lineSales = await getSalesForInvoice(db, unpaid.id); + const hammerSubtotal = roundMoney( + lineSales.reduce((a, s) => a + s.amount, 0) + ); + const parts = computeInvoiceTotalsFromParts( + hammerSubtotal, + unpaid.manualLines, + unpaid, + event + ); + const totalsUnchanged = + parts.subtotal === unpaid.subtotal && + parts.buyersPremiumAmount === unpaid.buyersPremiumAmount && + parts.taxAmount === unpaid.taxAmount && + parts.total === unpaid.total; + if (totalsUnchanged) { + return { kind: "updated", invoiceId: unpaid.id }; + } + } + // Atomic allocate + recompute + persist so live queries fire once + // (no flicker between "partial total" and "full total"). + await db.transaction( + "rw", + [db.events, db.invoices, db.sales], + async () => { await db.events.update(eventId, { updatedAt: new Date() }); for (const s of unallocated) { if (s.id != null) { await db.sales.update(s.id, { invoiceId: unpaid.id }); } } - }); - } - await recalculateAndPersistInvoice(db, unpaid.id, event, { - touchGeneratedAt: true, - }); + const lineSales = await db.sales + .where("invoiceId") + .equals(unpaid.id!) + .toArray(); + const hammerSubtotal = roundMoney( + lineSales.reduce((a, s) => a + s.amount, 0) + ); + const parts = computeInvoiceTotalsFromParts( + hammerSubtotal, + unpaid.manualLines, + unpaid, + event + ); + await db.invoices.update(unpaid.id!, { + subtotal: parts.subtotal, + buyersPremiumAmount: parts.buyersPremiumAmount, + taxAmount: parts.taxAmount, + total: parts.total, + // Only bump generatedAt when sales were actually allocated so + // pure recalcs don't reshuffle the invoice list. + ...(unallocated.length > 0 ? { generatedAt: now } : {}), + }); + } + ); if (event.syncId) { await enqueueInvoicePut(db, event.syncId, unpaid.id); } @@ -245,7 +298,7 @@ export async function upsertInvoiceForBidder( let id = 0; await db.transaction("rw", [db.events, db.invoices, db.sales], async () => { await db.events.update(eventId, { updatedAt: new Date() }); - id = (await db.invoices.add({ + const initialInv: Omit = { eventId, bidderId, invoiceNumber, @@ -256,15 +309,32 @@ export async function upsertInvoiceForBidder( status: "unpaid", generatedAt: now, syncKey: newEntitySyncKey(), - })) as number; + }; + id = (await db.invoices.add(initialInv)) as number; for (const s of unallocated) { if (s.id != null) { await db.sales.update(s.id, { invoiceId: id }); } } - }); - await recalculateAndPersistInvoice(db, id, event, { - touchGeneratedAt: true, + const lineSales = await db.sales + .where("invoiceId") + .equals(id) + .toArray(); + const hammerSubtotal = roundMoney( + lineSales.reduce((a, s) => a + s.amount, 0) + ); + const parts = computeInvoiceTotalsFromParts( + hammerSubtotal, + undefined, + { ...initialInv, id } as Invoice, + event + ); + await db.invoices.update(id, { + subtotal: parts.subtotal, + buyersPremiumAmount: parts.buyersPremiumAmount, + taxAmount: parts.taxAmount, + total: parts.total, + }); }); if (event.syncId) { await enqueueInvoicePut(db, event.syncId, id); diff --git a/lib/services/snapshotMerge.test.ts b/lib/services/snapshotMerge.test.ts index 3c5ae6a..2210f44 100644 --- a/lib/services/snapshotMerge.test.ts +++ b/lib/services/snapshotMerge.test.ts @@ -372,6 +372,70 @@ describe("mergeServerSnapshotIntoLocal", () => { }); describe("invoices", () => { + it("preserves local paid status when an older snapshot says unpaid", async () => { + const localBidderId = (await db.bidders.add({ + eventId, + paddleNumber: 5, + firstName: "Bob", + lastName: "Jones", + createdAt: new Date(), + updatedAt: new Date(), + })) as number; + // Local invoice was just paid (newer generatedAt). + const localInvId = (await db.invoices.add({ + eventId, + bidderId: localBidderId, + invoiceNumber: "INV-001", + subtotal: 100, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 100, + status: "paid", + paymentMethod: "cash", + paymentDate: new Date("2026-01-03T12:00:00.000Z"), + generatedAt: new Date("2026-01-03T12:00:00.000Z"), + syncKey: "inv-key-1", + })) as number; + + // Server says unpaid with a strictly newer generatedAt (e.g. teammate + // re-recalculated after pay): without the paid-protection guard, the + // merge would overwrite local paid → unpaid. + const payload = makePayload({ + bidders: [ + { + legacyId: 50, + paddleNumber: 5, + firstName: "Bob", + lastName: "Jones", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + invoices: [ + { + invoiceNumber: "INV-001", + subtotal: 100, + buyersPremiumAmount: 0, + taxAmount: 0, + total: 100, + status: "unpaid" as const, + generatedAt: "2026-01-04T00:00:00.000Z", + syncKey: "inv-key-1", + legacyBidderId: 50, + }, + ], + }); + + await mergeServerSnapshotIntoLocal(db, eventId, payload); + + const local = await db.invoices.get(localInvId); + expect(local?.status).toBe("paid"); + expect(local?.paymentMethod).toBe("cash"); + expect(local?.paymentDate).toEqual( + new Date("2026-01-03T12:00:00.000Z") + ); + }); + it("adds server-only invoices matched by syncKey", async () => { await db.bidders.add({ eventId, diff --git a/lib/services/snapshotMerge.ts b/lib/services/snapshotMerge.ts index af72330..9dabe9f 100644 --- a/lib/services/snapshotMerge.ts +++ b/lib/services/snapshotMerge.ts @@ -295,6 +295,12 @@ async function mergeImpl( summary.invoicesAdded++; } else { if (safeMs(sinv.generatedAt) > safeMs(local.generatedAt)) { + // Paid-status protection: never let an older paid status be + // silently flipped back to unpaid by a teammate's later recalc + // that did not include the payment. Preserve local paid + + // payment fields when the incoming snapshot row is unpaid. + const preservePaid = + local.status === "paid" && sinv.status === "unpaid"; await db.invoices.update(local.id!, { bidderId: resolvedBidderId, invoiceNumber: sinv.invoiceNumber, @@ -302,10 +308,18 @@ async function mergeImpl( buyersPremiumAmount: roundMoney(sinv.buyersPremiumAmount), taxAmount: sinv.taxAmount, total: sinv.total, - status: sinv.status, - paymentMethod: sinv.paymentMethod, - paymentDate: sinv.paymentDate ? parseDate(sinv.paymentDate) : undefined, - generatedAt: parseDate(sinv.generatedAt), + status: preservePaid ? "paid" : sinv.status, + paymentMethod: preservePaid + ? local.paymentMethod + : sinv.paymentMethod, + paymentDate: preservePaid + ? local.paymentDate + : sinv.paymentDate + ? parseDate(sinv.paymentDate) + : undefined, + generatedAt: preservePaid + ? local.generatedAt + : parseDate(sinv.generatedAt), buyersPremiumRate: sinv.buyersPremiumRate, taxRate: sinv.taxRate, manualLines: sinv.manualLines, diff --git a/lib/sync/ops/applyRemoteOp.ts b/lib/sync/ops/applyRemoteOp.ts index fdc7233..d821a38 100644 --- a/lib/sync/ops/applyRemoteOp.ts +++ b/lib/sync/ops/applyRemoteOp.ts @@ -263,7 +263,26 @@ async function applyRemoteOpImpl( message: "Invoice bidder conflict", }; } - await db.invoices.update(existing.id, base); + // Paid-status protection: if a teammate's recalc/full-put would + // overwrite a locally-paid invoice with status=unpaid, preserve our + // paid status, paymentMethod and paymentDate. Also keep our + // generatedAt so this device wins LWW until a real "mark unpaid" + // arrives (which would carry status=unpaid AND no paymentDate). + let merged: Omit = base; + if (existing.status === "paid" && p.status === "unpaid") { + merged = { + ...base, + status: "paid", + generatedAt: existing.generatedAt, + ...(existing.paymentMethod != null + ? { paymentMethod: existing.paymentMethod } + : {}), + ...(existing.paymentDate != null + ? { paymentDate: existing.paymentDate } + : {}), + }; + } + await db.invoices.update(existing.id, merged); } else { await db.invoices.add(base); } @@ -302,6 +321,9 @@ async function applyRemoteOpImpl( const d = new Date(v); if (!Number.isNaN(d.getTime())) row.paymentDate = d; } + } else if (k === "generatedAt" && typeof v === "string") { + const d = new Date(v); + if (!Number.isNaN(d.getTime())) row.generatedAt = d; } else if (k === "manualLines" && Array.isArray(v)) { row.manualLines = v as Invoice["manualLines"]; } else if (k === "status" && (v === "paid" || v === "unpaid")) {