Skip to content

DuplicateBillAlert

Blocking. Duplicate payment is the most common AP loss.
Implemented
Status
Implemented
Family
11 of 13
Registry
@lopifi/duplicate-bill-alert
npx shadcn@latest add @lopifi/theme @lopifi/duplicate-bill-alert

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

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

Lopifi dependencies · 8
buttoncnfiscal-periodformat-moneyiconsmoneymoney.typestooltip
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.

components/ledger/duplicate-bill-alert.tsx
"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)

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

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.

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.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.