Tax & compliance
Domain in domain/06-procure-to-pay.md (1099 tracking, sanctions screening), domain/08-controls-and-audit.md (control grades, approvals bound to a version) and domain/13-us-gaap.md (sales tax as a liability).
See also: #141 AuditTrail, #142 EvidencePanel and #144 MaterialityPolicy in close, controls & consolidation. ControlTestResult sits beside them and speaks their idiom.
The rule is data. A filing deadline, a form name, a reporting threshold, a sanctions list, a tax rate, the wording of a control conclusion: each one varies by jurisdiction and by year, so each one arrives as a prop and none is a constant in a component. A component that knew "the" threshold would be wrong the year it changed, and wrong silently.
Every component in this family takes an explicit asOf. "Today" is never the client clock, so a server render, a printed copy and a screenshot agree on what is late. Shared types live in src/ledger/lib/compliance.types.ts; the tax-ID checks live in src/ledger/lib/tax-id.ts, pure, so a server can run the same check the field ran.
188. ComplianceCalendar#
Every filing the entity owes: obligation, form, jurisdiction and agency, the period covered, the due date, the owner, the status, and the date penalties start. Two views: upcoming puts open filings soonest first, so past-due ones sit at the top and filed ones go last, and month groups every filing under the month its governing date falls in.
Filing calendar Northwind Ltd · as of 18 Oct 2026
1 past due · 1 due soon · 1 without an owner
─ Open ───────────────────────────────────────────────────── 5 filings
15 Oct Payroll tax deposit [Late]
3 days Federal · IRS · September 2026 · A. Whitfield Penalties from 16 Oct 2026
past due
Status is supplied, not derived. upcoming | due-soon | filed | late | extended. Whether a return is "due soon" depends on business days, holidays and the team's own lead time, and the consumer knows those; the component does not. It does compute the day count from asOf, by calendar date, so a clock time never makes a return late.
An extension moves the filing date, and only that. The row then shows the extended date, the original date beneath it, and, when the consumer supplies a separate paymentDueDate, the payment date too, because an extension to file isn't always an extension to pay.
Refuses to render: a penalty date nobody stated as "no penalty". A null penalty date prints "Penalty date not stated". A null owner prints "Unassigned" in the warning tone and is counted in the header. It's an action, not a blank.
Edge cases: no obligations at all says so and asks whether the registrations behind it are complete, because an empty calendar is a claim. A filing with no form name shows the obligation alone. A filed row shows the filing date and the agency's confirmation number.
Accessibility: each group is a labeled section holding an ordered list. Status is an icon plus a word. The past-due day count carries weight as well as color, so it reads in grayscale.
189. SalesTaxSummary#
By jurisdiction: gross sales, exempt, taxable, rate, tax collected, tax due, and the return's state (open | filed | paid). Totals sit under single and double rules over the figure columns only.
The tie-out is the point. The unpaid liability on the returns (every row not yet paid) is compared to the sales-tax-payable balance in the GL, and the difference is on screen. Three renders: ties, differs (an alert, with the signed difference), and not tied out. A null GL balance never compares against zero; the component states that no tie-out was performed.
Each row proves itself. Gross less exempt must equal taxable. A row that doesn't gets a glyph in the taxable cell and is named in an alert above the table. A gap between collected and due is printed under the jurisdiction as "Collected $31.80 less than due". Over- and under-collection are both findings, in opposite directions.
Refuses to render: a computed average rate. A row blending several rates passes rate: null and prints "Blended". Mixed currencies are never summed either: one currency prop governs every row.
Edge cases: a 0-decimal currency (the amounts follow the currency's exponent through Money). Every row paid, so the unpaid liability is zero and ties to a zero GL balance. A jurisdiction exempt in full.
Accessibility: a real <table> with a caption naming the period and currency, row headers per jurisdiction, and the frozen header and first column from .ledger-frozen (CSS only, so the component stays server-safe).
190. TaxFormTracker#
Vendors paid over a reporting threshold, and what stands between each of them and a filed information return: total paid, reportable amount, W-9 state, TIN-match result, form type, delivery and filing.
The threshold is a prop and is printed in the header. "Vendors paid at least $2,000 in reportable payments · tax year 2026". The figure changes by law and by year (procure to pay keeps it as a dated table), and so does its wording: comparison: "at-least" | "more-than" has no default, because whether a vendor paid exactly the threshold is in or out depends on the rule's own text. Vendors under the threshold are counted ("1 under it, not listed"), not silently dropped.
Reportable is not total paid. Payments by card or through a third-party network are typically reported by the processor, not the payer, so the threshold is tested against reportableAmount, and both columns are shown.
Rows that need work sort first: a W-9 that's missing or requested, a TIN mismatch, an undetermined form, an undeliverable copy, a rejected filing. The header alert counts missing W-9s and mismatches, because before year-end they're still fixable.
Refuses to render: a full TIN. The list shows the last four at most, masked, with spoken text "TIN ending 4471".
Accessibility: every state is a glyph plus a word (Matched, Mismatch, Not run). The table has a caption naming the tax year.
191. TaxIdInput#
One field for ein | ssn | itin | abn | gst | vat, each in its issuer's printed form, checked by the issuer's published algorithm where one exists and by format where none does. The result says which kind of check passed.
| Scheme | Printed | Check |
|---|---|---|
ein |
12-3456789 |
Format only: nine digits. There's no check digit, and the IRS's valid-prefix list changes, so a stale copy here would reject a real number. |
ssn |
123-45-6789 |
Format: the area is never 000, 666 or 900–999, the group never 00, the serial never 0000. |
itin |
9XX-XX-XXXX |
Format: a leading 9, with the fourth and fifth digits in 50–65, 70–88, 90–92 or 94–99. |
abn |
12 345 678 901 |
Checksum: subtract 1 from the first digit, apply the weights 10, 1, 3, 5 … 19, and the sum must be divisible by 89. |
gst |
123456789 RT0001 |
Canadian GST/HST: the nine-digit business number must pass Luhn, followed by the RT program account. |
vat |
DE123456789 |
Format by country prefix only (EU member states, XI, GB). An unknown prefix is "not checked", not invalid. |
A pass is not a verification. A well-formed number may never have been issued. Registry lookups (IRS TIN matching, VIES, HMRC, ABN Lookup) belong to the consumer's backend and come back through error.
Masked whenever it isn't focused. Every character but the last four is a bullet in the DOM, so a screenshot, a screen share or a copied selection carries the mask, not the number. A VAT number keeps its country prefix, since the prefix isn't the secret. Focus shows the whole number, and formatting applies on blur only, because inserting a hyphen mid-typing moves the caret. With name, a hidden input posts the real value. The visible field never does.
Never logged: no console output, no data attribute, autoComplete="off", no spellcheck. type="text" with the scheme's inputMode, and paste is never blocked.
Validation timing: "incomplete" waits until the field is left. A wrong number at full length says so at once. The message line is reserved, so a result arriving doesn't move the form. It crossfades in and never shakes.
Accessibility: a real <label>. aria-invalid and aria-describedby point at the message. At rest the masked field is described as "SSN hidden, ending 6789. Focus the field to show it."
192. VerificationStatus#
KYC for a person, KYB for a business: the subject, every requirement (ID document, address, beneficial owners, sanctions screening …) with its state (verified | pending | needs-action | failed), and what's still due, by when, and what happens if it isn't provided. Requirements, deadlines and consequences come from the provider or the platform. The component names none of its own.
The overall state is derived, worst first. One failed requirement makes the subject failed, never an average. An empty requirement list is "No requirements on record", never "Verified".
Next due is stated in the summary line ("Next due: Beneficial owners by 24 Oct 2026"), and an overdue requirement counts its days in the negative tone. onResolve adds an action to requirements that need action or failed. Without it, no action renders.
Accessibility: the action's accessible name includes the requirement ("Provide beneficial owners"). Pending is a clock, never a spinner: it's a state, not a load.
193. SanctionsScreening#
One screening hit, laid out so a reviewer can decide it: the screened name beside the listed name, a field-by-field comparison (exact, partial, no match), the list source with its entry id and refresh date, the provider's match strength and score, and the disposition (true-match | false-positive | escalated) with reviewer, timestamp and rationale.
Dispositions are append-only. There's no clear, no dismiss and no edit. A changed mind is a new record with its own reason, and the earlier one stays in the history. Paying a sanctioned party is a legal event, not an accounting one (procure to pay), and a hit that vanished without a reviewer's name is exactly what an examiner asks about.
Refuses to render: an anonymous disposition. The form names the reviewer and requires a rationale, and without reviewer and onRecord the hit renders read-only. An escalated hit is still open.
Data, not constants: the list's name, version and refresh date, and the strength thresholds, are the provider's. Demos use an illustrative list and fictional parties.
Accessibility: native radio inputs, so the browser owns the arrow keys. A real <form> and <fieldset>. The current disposition is a status region.
194. ControlTestResult#
One control, one test, one conclusion: control id and description, owner, frequency, test type (design or operating-effectiveness), sample size against population, every exception (the sample item, what was found, the amount, the remediation), and the conclusion.
The grade uses the auditor's vocabulary from controls & audit: effective, control deficiency, significant deficiency, material weakness. Never a generic "issue". The wording is data. CONCLUSION_COPY is the default, and a firm with its own methodology passes conclusionCopy rather than forking the component.
A material weakness owns its surface, with a 2px negative border and an alert, as a failing IntegrityCheckPanel does.
The component asserts:
- A sample larger than the population is an error. One of the two figures is wrong.
- "Effective" with exceptions and no rationale is flagged. That conclusion can be right, but only with a reason.
- A
nullpopulation prints "population not stated", never "of 0". An exception with anullamount prints "Not quantified", never $0.00.
Accessibility: the conclusion is an icon plus a label, and its consequence is spelled out in a sentence.
195. PolicyAttestation#
A policy's name, version and effective date, who must attest, who has, who hasn't, and the overdue count.
Acknowledgments bind to a version, as approvals do (controls & audit §3). Someone who acknowledged v3.0 hasn't acknowledged v3.1. They're counted as outstanding, and the row says "Acknowledged 3.0, not this version" so an old signature never stands in for a new policy.
Overdue is every outstanding attestor once asOf passes dueBy. Before the deadline the overdue count is zero, and the outstanding list says why each person is on it. onRemind adds a single "Remind N" action for the outstanding attestors.
Refuses to animate the progress bar. It's a count, and a bar that grows is a number being animated.
Edge cases: nobody assigned to attest says so, rather than showing 0 of 0 as complete.
Accessibility: the bar is a progressbar with its value and an accessible label ("3 of 5 attested"). The counts are a description list.