DuplicateBillAlert
- Status
- Implemented
- Family
- 11 of 13
- Registry
- @lopifi/duplicate-bill-alert
npx shadcn@latest add @lopifi/theme @lopifi/duplicate-bill-alertThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
Duplicate payment is one of the most common — and most preventable — AP losses.
Runs before save. Primary key: vendor + vendorInvoiceNumber. Secondary fuzzy check: vendor + amount + date ± 5 days. Blocking, showing the suspected duplicate side by side with the new entry. Also detects duplicate vendors by name similarity and tax ID — duplicate vendors are how duplicate payments survive duplicate-invoice checks.
Lifted verbatim from Payables (AP). The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/duplicate-bill-alert.tsx
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/duplicate-bill-alert.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import { formatDate } from "@/lib/ledger/fiscal-period";
import type { MoneyValue } from "@/lib/ledger/money.types";
import { Button } from "@/components/ledger/button";
import { DocumentDuplicateIcon, ExclamationTriangleIcon } from "@/components/ledger/icons";
import { Money } from "@/components/ledger/money";
export type BillFingerprint = {
id: string;
vendor: string;
vendorInvoiceNumber: string;
invoiceDate: Date;
amount: MoneyValue;
postedAt?: Date;
};
export type DuplicateMatch = {
existing: BillFingerprint;
/** Primary key: vendor + their invoice number. Secondary: fuzzy. */
kind: "exact-invoice-number" | "fuzzy-amount-date" | "duplicate-vendor";
detail: string;
};
export interface DuplicateBillAlertProps {
candidate: BillFingerprint;
className?: string;
matches: DuplicateMatch[];
onCancel?: () => void;
onProceedAnyway?: (reason: string) => void;
}
/**
* **Duplicate payment is the single most common AP loss.**
*
* Runs before save, and it is **blocking** — the suspected duplicate is
* shown side by side with the new entry. Duplicate *vendors* are detected
* too, because that is how duplicate payments survive duplicate-invoice
* checks.
*/
export function DuplicateBillAlert({
candidate,
matches,
onProceedAnyway,
onCancel,
className,
}: DuplicateBillAlertProps) {
const [reason, setReason] = React.useState("");
if (matches.length === 0) return null;
return (
<div className={cn("rounded-xl border-2 border-negative bg-surface p-6", className)} role="alert">
<p className="flex items-center gap-2 text-title-sm font-medium text-negative">
<ExclamationTriangleIcon aria-hidden="true" className="size-5 shrink-0" />
This looks like a duplicate. Saving is blocked.
</p>
{matches.map((match) => (
<div className="mt-4" key={match.existing.id}>
<p className="flex items-center gap-1.5 text-caption text-text-secondary">
<DocumentDuplicateIcon aria-hidden="true" className="size-3.5" />
{match.kind === "exact-invoice-number"
? "Same vendor and vendor invoice number"
: match.kind === "fuzzy-amount-date"
? "Same vendor and amount, within five days"
: "Two vendor records look like the same vendor"}{" "}
— {match.detail}
</p>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<Side bill={candidate} title="New entry" tone="new" />
<Side bill={match.existing} title="Already in the ledger" tone="existing" />
</div>
</div>
))}
<label className="mt-4 block">
<span className="mb-1 block text-label font-medium text-text-secondary">
If this genuinely is not a duplicate, say why
</span>
<input
className="h-10 w-full rounded-sm border border-border bg-surface px-3 text-body outline-none focus-visible:border-accent"
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. vendor reissued the invoice with a new number after a correction"
value={reason}
/>
</label>
<div className="mt-3 flex flex-wrap gap-2">
<Button onClick={onCancel} variant="secondary">
Cancel this bill
</Button>
<Button
disabled={reason.trim().length < 10}
onClick={() => onProceedAnyway?.(reason)}
variant="destructive"
>
Save anyway — recorded with the reason
</Button>
</div>
</div>
);
}
function Side({ title, bill, tone }: { title: string; bill: BillFingerprint; tone: "new" | "existing" }) {
return (
<div
className={cn(
"rounded-md border p-3",
tone === "new" ? "border-accent bg-accent-subtle" : "border-border bg-surface-sunken",
)}
>
<p className="text-caption font-medium text-text-secondary">{title}</p>
<p className="mt-1 text-body font-medium">{bill.vendor}</p>
<p className="reference-numeric text-caption text-text-secondary">{bill.vendorInvoiceNumber}</p>
<p className="numeric text-caption text-text-secondary">
{formatDate(bill.invoiceDate)}
{bill.postedAt ? ` · posted ${formatDate(bill.postedAt)}` : ""}
</p>
<Money className="mt-1" size="md" value={bill.amount} />
</div>
);
}
The accounting it encodes
Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.
Also in Payables (AP)
- BillFormPer-line coding, required evidence, discount ROI surfaced.Spec
- BillDetailSource document inline beside the coding.Spec
- BillRegisterAging and approval state.Spec
- ThreeWayMatchLine-level variances, not header totals.
- MatchExceptionQueueThe clerk's worklist.Spec
- ApprovalChainSegregation of duties enforced. Approvals bind to a version.
- ApprovalInboxMobile-first, document inline.
- PaymentRunBuilderShows the cash balance remaining after the run.Spec
- PaymentRunPreviewHold-to-confirm. Partial failure is a first-class state.Spec
- CashRequirementsDue in 7/14/30 vs. available, payroll broken out.Spec
- VendorMasterBank details versioned, never overwritten.Spec
- BankDetailChangeAlertThe fraud-prevention surface.Spec
- ExpenseCaptureCamera-first, policy violations shown at entry.Spec
- CardTransactionMatchMatched to receipts, never re-entered.Spec
- UninvoicedReceiptsReportGR/IR aging. The evidence for the period-end accrual.Spec
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Payables (AP) — the family specThe four-layer specification this component was built against, before it was written.Doc
- Payables — procure to payThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- Accounts payableWhat we owe vendors for bills already receivedTerm
Elsewhere in the system
Where DuplicateBillAlert sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
- DuplicateBillAlert, runningThe ap showcase — live, with theme, density, greyscale and reduced-motion switches.Live
- Duplicate paymentsSame vendor, same amount, close in time — plus the same reference whatever the date.Tool
- Payables (AP)Payables (AP) — specs for 16 React components: BillForm, BillDetail, BillRegister, DuplicateBillAlert and 12 more.Doc
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Payables (AP) changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.