uid: T-183 title: Employee payment readiness β abbreviation, legal name, bank account, and prompting for them status: done area: accounting created: 2026-07-25 updated: 2026-07-27 owner: girafeev1 related: T-178
T-183 β A person the company can actually pay¶
Why¶
Issuing a Payment Confirmation to an employee needs three things onboarding never collects. Verified
2026-07-25: lib/individuals/ensureForUser.server.ts runs on the invitation sign-in path, so every
login already gets a Contacts record β but a deliberately bare one:
abbreviation: '' β blank; a placeholder is derived from the minted id
legal.legalName: null β the WOPC's "To:" line
legal.bankAccounts: [] β the WOPC's "Paid on β¦ to:" block
system.position: null β the closing's title line
So the common failure is not "this person has no record" β it is that the record holds none of what a
payment needs. A blank abbreviation is the default state of every onboarded user, and the WOPC
reference number is built from it.
There is also no self-service surface: /api/profile/* covers name preferences, notifications,
Telegram and (as of T-178) me. Bank accounts are edited only in Contacts, a bookkeeper surface.
Owner, 2026-07-25: "I wouldn't require the user to input their bank information during their onboarding to the app, but the web app should remind the user or related personnel at some [point] that a user must submit their bank account information so that a WOPC could be issued to them whenever their usage or simple existence touches upon any sort of payment."
Scope¶
- A readiness check on the Individual β abbreviation, legal name, at least one bank account. Derived, not stored, so it cannot go stale.
- Surface it before it bites: the Reimbursements tab flags an employee as unpayable before receipts are selected, rather than failing at Confirm. The same check gates the issue button.
- Self-service prompt when a user's own record is short of what a payment needs, with a narrow self-scoped write for their own bank details.
- Not at onboarding β the owner explicitly does not want bank details demanded up front.
Related¶
T-178 surfaces this today as a 409 with a plain-English message naming what is missing.
Log¶
2026-07-27 β Readiness built and enforced; the placeholder-abbreviation defect it uncovered¶
β Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-183. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
Firestore: no structure change. This work only reads individuals/{id} (abbreviation, legal).
Readiness is derived on every read and deliberately not stored β a stored flag would go stale the moment
somebody fills a field in, and would need a backfill nobody would remember to run.
The defect this found, which is the reason the task mattered more than it looked. The task premise
said a freshly-onboarded record carries abbreviation: '', so T-178's no-abbreviation guard would
catch it. That is not what happens. createIndividualServer substitutes a fallback when none is supplied:
// lib/individuals/crud.server.ts:53
tx.create(indRef, { ...individual, id, abbreviation: abbr || id.slice(0, 8).toUpperCase(), β¦ })
So every self-onboarded user carries an 8-character slice of their opaque Firestore doc id as their
abbreviation. It is not empty, so the guard never fired β it was dead code for exactly the population it
was written for. ~~The real failure was never a refusal; it was issuing
ERL-WOPC/K3MZ8QP1-2026-001 β a permanent, unreadable reference number on a document that leaves the
company and cannot be recalled. Silently wrong beats loudly wrong here, and it was silent.~~
β superseded by the 2026-07-28 correction entry below: since #664 the reference number carries no
abbreviation, so this scenario could not occur; the placeholder's real costs are the payee doc key,
the directory key and cross-issuance. Edit-out signed: Records (Infrastructure) Β·
https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
Detection is exact rather than heuristic: the placeholder is defined as id.slice(0, 8).toUpperCase(),
so it is re-derived and compared. (Someone who deliberately picks an abbreviation equal to the first eight
characters of their own doc id reads as unready and is asked to set one β harmless, and vanishingly
unlikely against a 20-character opaque id.)
Also corrected: isSection1Complete claimed more than it delivered. Its docstring read "= can be
PAID / appear on a WOPC", but it checks only legal name + bank account β not the abbreviation the
reference number is minted from. The claim is now scoped to "appears on a WOPC / tax forms" and points at
the new predicate. assessPaymentReadiness builds on legalNamePresent / hasBankAccount rather
than restating them, so there is one definition of "has a legal name", not two.
What shipped
lib/individuals/paymentReadiness.ts(new, pure, client+server safe) βassessPaymentReadinessreturns{ ready, gaps[] }where each gap carries a shortlabelfor a tag and a fulldetailsentence naming what is missing, what needs it, and where it is fixed. PlusisPlaceholderAbbreviation,hasUsableAbbreviation,summarisePaymentReadiness.lib/accounting/reimbursementPayee.server.tsβ the readiness gate replaces the emptiness check;ReimbursementPayeeError.reasonno-abbreviationβnot-payment-ready, now carryinggapsso the client lists fields instead of parsing prose. It also stopped hand-rolling its ownwhere('system.userUid', β¦)query and uses the store'sgetIndividualByUserUid, so there is one definition of "the Individual behind this account".GET /api/accounting/reimbursementsβ each group gains a derivedpaymentReadiness, resolved in one chunked bulk read (getIndividualsByUserUids) off the uid the display-name lookup was already fetching, so it costs no extra round trip per person.components/accounting/ReimbursementsTab.tsxβ an unpayable employee wears a "Can't be paid yet" tag on their row with the specific gaps on hover, before any receipt is selected; the sticky bar's Reimburse button and the modal's OK are disabled with the reason on hover. A 409 now re-reads the feed so the flag appears rather than the operator hitting it twice to learn why.GET /api/profile/meβ returns the caller's ownpaymentReadiness(self-scoped; no id parameter, so it can never report on anyone else). This is the data half of the self-service prompt.
Where the gate lives, and why in two places. The tab's check is a convenience; the one in
resolveReimbursementPayee is the guarantee. Both call the same assessPaymentReadiness, so they cannot
drift. The client blocks only on a known failure β when readiness is null (no linked Contacts
record, or the directory read degraded) the attempt is allowed through and the server answers with the
precise reason, which beats guessing in the browser.
Verified. Unfiltered tsc --noEmit --incremental false clean Β· vitest run 729 passed, the only 4
failures being the known pre-existing ones in __tests__/pages/api/workspace/billing/ingest.test.ts
(untouched by this diff β nothing in it imports the billing ingest path) Β· eslint clean on all eight
changed files Β· 16 new unit tests covering the placeholder cases, the freshly-onboarded record, and a
title-only legal name.
Still open at the time of this entry: the self-service half β delivered in the entry below.
Blast radius. ReimbursementPayeeError.reason changed value (no-abbreviation β not-payment-ready)
β verified no client reads reason by name, only error. The reimbursements feed gained a field
(additive). The Reimbursements tab is the only UI touched. Anyone working near Contacts should know that
isSection1Complete is not the "can we pay this person" predicate.
2026-07-27 β Self-service half: the profile prompt and a narrow own-record write¶
β Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-183. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
Completes the task's remaining scope bullet. Owner, 2026-07-27: "Complete the remaining half of T-183 first".
Firestore: no structure change. legal.legalName and legal.bankAccounts already exist and already
hold exactly this shape β this writes the fields Contacts has always written, through the same
updateIndividualServer, which shallow-merges the legal section so hkid / marital status / Chinese name
survive untouched, and runs the existing individualβpayee write-through. A self-service edit therefore
lands identically to a bookkeeper's.
What the user can now do, and what they deliberately cannot
PUT /api/profile/payment-details writes only legal.legalName and legal.bankAccounts. Two
exclusions are deliberate:
abbreviationstays bookkeeper-owned. It is a UNIQUE directory key appearing in every reference number the company issues; a collision has to be resolved against records a self-scoped endpoint cannot see, and self-assignment invites both collisions and vanity keys. When that is the missing piece, the card says so plainly β "it isn't something you can choose yourself" β and routes the person to a bookkeeper rather than offering a field that would fail. This is the one gap the user cannot close, and the UI is honest about it instead of pretending otherwise.system.*(position, director facet, userUid, related-party) β assertions the company makes about a person, not ones they make about themselves.system.director.isDirectorgates WOPC signing, so a self-service write there would be a privilege-escalation surface.
The endpoint is self-scoped by construction: no id parameter, the Individual is resolved from the session every time, so it cannot be pointed at anyone else's record whatever the caller sends.
The prompt. A "Payment details" card on the profile page, directly under Directory & Contact Info.
It is silent when nothing is missing β a card that nags a complete record trains people to ignore it β
and shows a warning listing the specific gaps when something is. Banks are picked from the shared Hong
Kong bank registry (useHongKongBanks), the same source the Contacts editor uses, so a self-entered
account is indistinguishable from a bookkeeper-entered one. The PUT returns the stored state, so what
the user sees after saving is what actually landed β including renumbered identifiers and the promoted
default, rather than an optimistic echo of what they typed.
Per the owner, this is not at onboarding: "I wouldn't require the user to input their bank information during their onboarding to the app, but the web app should remind the user or related personnel at some [point]β¦"
One duplication removed on the way. The Contacts editor normalised bank accounts inline in the
component β uppercase the identifier, strip trailing digits, drop blank holders, renumber per bank,
promote a default. A second copy in the new endpoint would have been two dialects of the same data, so it
was extracted to normaliseBankAccountsForWrite and both paths now call it. That matters beyond tidiness:
a person's account should not look different in Firestore depending on whether they or a bookkeeper typed
it.
It landed in lib/bankAccounts.ts, not payeeDirectory.ts, because payeeDirectory.ts imports
Firebase at module load and therefore cannot be unit-tested without real config β the first attempt failed
exactly that way. normalizeIdentifier and assignReadIdentifiers moved with it and are re-exported
from payeeDirectory.ts, so every existing import site is unchanged: one implementation, reachable from
where callers already look.
Verified. Unfiltered tsc --noEmit --incremental false clean β it caught six implicit-any handler
params and a missing internal import that a filtered run would have missed, which is the case for the
AGENTS.md gate in miniature. vitest run 738 passed, the only 4 failures being the known pre-existing
ones in __tests__/pages/api/workspace/billing/ingest.test.ts. eslint clean on the changed files except
one pre-existing no-useless-escape in lib/payeeDirectory.ts β verified against a stashed HEAD
(same error, line 221 before the edit shifted it to 208); left alone rather than folded into an unrelated
diff. 9 new tests pin the normalisation, particularly the "exactly one default" rule the WOPC payment
block depends on.
All four scope bullets are now delivered.
2026-07-27 β Close-out (status β done)¶
β Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-183. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
Verdict: delivered in full β the derived readiness check, its enforcement at both the tab and the
issue endpoint, the bookkeeper-facing "Can't be paid yet" surfacing, and the self-service profile card
with its narrow own-record write. The placeholder-abbreviation defect (every self-onboarded record
carries id.slice(0,8) as its abbreviation, so the old emptiness guard never fired) is the finding that
outlives the task. Verified by unfiltered tsc, vitest (738 passed; 4 pre-existing billing-ingest
failures), eslint on changed files, and 25 new unit tests.
The status flips to done in this branch so that, when PR #933 merges, the code and the board state
land on main in the same merge β the AGENTS.md pairing. (An earlier line here said the task "stays
doing until merge"; that read the rule backwards β the flip has to pre-exist in the branch for the
pairing to hold, since the owner merges on GitHub, not via a follow-up commit.)
Commit SHAs (append-only, newest last): 2df4f50 readiness gate + tab/issue enforcement Β·
5e200e6 self-service payment details + prompt Β· abfefc0 blast-radius note correction Β· the T-180
board commit this close-out rides in.
Deploy: not deployed β merged-only per the standing policy; the deploy verification owed is the "Can't be paid yet" tag on an incomplete employee and the profile card's prompt/edit flow.
Blast-radius handoff: unchanged from the two entries above; additionally, the User Management
agent's profile-unification work (owner direction, 2026-07-27) will absorb PaymentDetailsCard and
the payment-details endpoint β the readiness predicate and its two enforcement points are the contract
that must survive that rework.
2026-07-28 β Correction: the placeholder never printed on a reference number¶
β Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-183. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
The User Management agent, researching T-188, verified what this task asserted but did not:
lib/wopc.server.ts:279 mints references as ERL-WOPC/{YYYY}-{NNN} with no abbreviation since
664 (the parameter survives as _abbreviation, explicitly unused), and the PDF renderer uses¶
payeeAbbreviation only to resolve the cross-issuance closing director. So this task's headline
scenario β ERL-WOPC/K3MZ8QP1-2026-001 on an outbound document β could not occur. I derived it
from generateNextWOPCNumber(year, abbreviation)'s signature without reading the body.
The gate itself stands, with its real justification: a placeholder abbreviation still keys
payees/{abbr} (creating a payee document named by a doc-id fragment), is the directory's human key
in Contacts, and feeds cross-issuance β and T-188 retires the placeholder at the source by having
the inviter assign the abbreviation. The decision-index row's rationale clause is corrected in the
same commit as this entry.
Follow-up handed to the owner, not built: stopping createIndividualServer from minting placeholder
abbreviations at creation (store empty instead). Needs owner approval β it changes write behaviour on a
shared creation path β and detection must stay regardless, for the installed base.
Blast radius. components/contacts/IndividualsContent.tsx now calls the shared normaliser instead of
its inline copy β near-identical, with one deliberate tightening rather than a pure move: the old
inline code only promoted a default when none was set and would have preserved multiple isDefault
flags had stored data ever carried them; the shared helper enforces exactly one (first marked wins,
extras demoted). The Contacts UI already made defaults exclusive, so no UI-reachable behaviour changes,
but a legacy record holding several defaults would now be normalised on its next save. It is a live
bookkeeper surface and worth knowing. (Wording corrected 2026-07-27 β the entry originally claimed
"behaviour-identical", which overstated it.)
lib/payeeDirectory.ts re-exports three functions it used to define; nothing that imports them changes.
The profile page gained a card. New endpoint /api/profile/payment-details.
Source¶
Split out of T-178 on 2026-07-25 at the owner's request β verbatim: "Please, if you feel the need, separate the tasks into multiple more tasks as I feel like this one/ two tasks with so many different things might come off as confusing (it's not a bill passed in the senate)." T-178 keeps the reimbursement payment pipeline (WOPC document, Reimbursements tab, issuance); everything about how expense documents are submitted and housed moved here.
Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
2026-07-28 β Codex review (P1 on #933) confirmed and fixed: a bank entry is not a payable account¶
β Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-183. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
Confirmed against the code: the write normalisation deliberately keeps a half-filled entry (bank
picked, account number blank β the person may finish it later), hasBankAccount only checks the
array is non-empty, and bankOf then omits bankAccountNumber β a person could read as ready and
receive a Payment Confirmation that cannot say where the money went.
Fixed in paymentReadiness.ts: the account the document will actually print (default-or-first, the
same selection bankOf makes) must carry a bank name and an account number
(isPayableBankAccount). The gap surfaces as "Bank account incomplete", distinct from
"No bank account". Four new tests pin it, including the case where an incomplete DEFAULT sits
beside a complete non-default β the default wins selection, so it is still a gap.
- 2026-07-28 β Merged to
mainin PR #933 (f2d310a), not deployed. SHA list gainscbe41f5(Codex round) and the merge SHA. Deploy verification pending per the task's own notes. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD