U8 — Dues (회비): Adversarial UI/UX Audit
Status: Archived Date: 2026-07-11 Scope: packages/features/clubs/src/club-dues-screen.tsx + packages/features/clubs/src/dues/** (admin + member views, action modals, month selector, summary card, row), packages/app/src/domain/entities/dues.entity.ts, packages/app/src/{application,ports,adapters,presentation}/**/dues*, signal wiring (00108, 00120, latest def in 00385), admin-snapshot RPC (00381), home-signals RPC (00103), i18n (ko/settings.ts, ko/signals.ts). Method: direct code read + grep sweep + migration diff-reading across the 4 live "overdue" definitions + cross-reference against docs/canon/*. No code modified.
Relationship to U2: U2 (docs/audits/blocks/U2-clubs.md finding #2) already found and fully documented that the 부분납부 (partial-payment) numeric amount is entered, accepted, then silently discarded (club-dues-screen.tsx:183-196, _amount unused param). Verified — still true, not re-litigated below. This audit extends it with a NEW angle (finding #2 here): the signal system's silence on partial/waived transitions, which U2 did not investigate.
Executive-severity ranking
| # | Finding | Severity |
|---|---|---|
| 1 | "면제" (waive) writes no audit trail, never notifies the member, and never resolves their outstanding overdue signals | CRITICAL |
| 2 | Four live, mutually-inconsistent definitions of "overdue" across four surfaces | CRITICAL |
| 3 | No bank-account / payment-instructions surface anywhere — member-side dues screen dead-ends at "contact your 총무" | HIGH |
| 4 | Ex-members' unpaid dues are immortal — no membership-active filter anywhere in the dues/signals pipeline | HIGH |
| 5 | Mid-month member join has no dues-generation path once the month's batch already exists | MEDIUM-HIGH |
| 6 | Dues signal tap loses temporal context — always routes to the CURRENT month, not the signal's month | MEDIUM |
| 7 | Currency-formatting drift: formatWon() vs t().common.won() disagree on the 원 suffix, same screen | MEDIUM |
| 8 | No bulk mark-paid — 2 taps × N members, zero batch affordance | MEDIUM |
| 9 | Dead i18n subtree: settings.ts's entire signals object is shadowed and unreachable, with drifted duplicate copy | MEDIUM (docs/dead-code) |
| 10 | Second orphaned i18n subtree settings.ts:dues describes unshipped features (duplicate-warning, send-reminder) | LOW-MEDIUM (dead code) |
| 11 | 0-dues clubs still show the full 회비 UI, generating meaningless ₩0 "미납" rows | LOW |
| 12 | DuesPartialPaymentModal reinvents amount parsing instead of reusing parseWon() | LOW |
1. CRITICAL — "면제" (waive) writes no audit trail, never notifies the member, never resolves their overdue signals
Evidence — no audit trail:
club-dues-screen.tsx:198-223(handleWaive) sends{ status: 'waived', clearedByUserId: userId }— the admin's identity is explicitly threaded through, same as the mark-paid path.dues.mapper.ts:50-63(toUpdateRow) only writescleared_by/cleared_atif (input.status === 'paid'). Forstatus: 'waived',clearedByUserIdis silently dropped — the columns 00120 built specifically for "who cleared this row" audit purposes stay whatever they were (usuallyNULL).- The client-side optimistic patch mirrors the same gap:
use-update-dues.ts:231-241(mergeDuesPatch) only setsclearedBy/clearedAtif (input.status === 'paid' && input.clearedByUserId). So there is no client/server disagreement — both sides consistently drop the waiver's audit trail. Consistent, but consistently wrong.
Evidence — no member notification:
packages/app/src/domain/entities/signal.entity.ts:73-79,294-300— the fullSignalTypeunion for dues isdues_assigned | dues_due_soon | dues_overdue | dues_severely_overdue | dues_paid | dues_cleared_by_admin | dues_refund_issued. There is nodues_waivedtype at all.signals_on_dues_update()(latest def:00385_alert_model_wiring.sql:853-919, superseding00120/00194) only branches insideIF NEW.status = 'paid' AND OLD.status IS DISTINCT FROM 'paid'. A transition to'waived'never enters this block at all — no signal of any kind fires. A member whose dues get waived receives literally zero in-app or push acknowledgment that it happened.
Evidence — outstanding overdue signals never resolve:
- The same
IF NEW.status = 'paid' …guard gates the foursignals_resolve_by_dedup_key('dues_overdue:'|'dues_severely_overdue:'|…)calls (00385_alert_model_wiring.sql:863-867). Resolution is paid-transition-only. signals_tick_dues()(00108_signals_dues_triggers.sql:169-265, unchanged since) only escalates rowsWHERE d.status = 'unpaid'— once a row flips to'waived', the cron stops emitting new escalations, but any signal already emitted before the waiver (e.g. adues_severely_overdue, critical severity, noexpires_atset — see lines 200-219) stays active forever, because nothing ever resolves it.
Impact (compounded): a 총무 who waives a severely-overdue member's dues — the exact "I'm letting this one go" gesture the feature exists for — (a) leaves zero record of who waived it or when, (b) never tells the member anything happened, and (c) leaves that member's device permanently carrying a critical-severity "회비 심각 연체" signal that will never resolve (no expiry, no future re-check, no admin-visible way to clear it short of raw SQL). The waive button visibly "works" (row flips to 면제 badge, success toast fires) while silently leaving the money-adjacent system in a worse, permanently-inconsistent state than before the admin acted — the same "looks-done, isn't" failure shape as U2's partial-payment bug, but one layer deeper (it corrupts cross-surface signal state, not just a UI field).
Fix shape: (a) dues.mapper.ts:toUpdateRow — write cleared_by/ cleared_at whenever clearedByUserId is supplied, not only for 'paid'; (b) add a dues_waived signal type + emit it (medium severity, i18n key signal.dues.waived.*) alongside the existing dues_cleared_by_admin branch shape; (c) broaden the resolve-on-transition guard from NEW.status = 'paid' to NEW.status IN ('paid', 'waived') so a waiver also resolves dues_assigned/dues_due_soon/dues_overdue/dues_severely_overdue. Same three fixes should extend to 'partial' — see finding #2.
2. CRITICAL — 'partial' transitions are equally invisible to the signal system (extends U2's partial-payment finding)
Evidence: same IF NEW.status = 'paid' … guard as #1 — a transition to 'partial' also never resolves dues_overdue/dues_severely_overdue and never emits any signal. Combined with U2's already-documented finding that the entered partial amount is discarded (club-dues-screen.tsx:183-196), the entire partial-payment status is decorative twice over: it can't record how much was paid (U2 #2), and it can't tell the recipient or clear their outstanding alerts (this finding). A member who pays half their dues and gets marked 'partial' by the 총무 keeps receiving escalating overdue pushes for the full original debt, indefinitely, with the 총무 UI simultaneously showing them as "partially paid" (contradicting the signal center, which still says "회비 심각 연체").
Fix shape: same broadened NEW.status IN ('paid', 'waived', 'partial') resolve-guard as #1; once U2's partial-amount schema fix lands, a partial payment's remaining balance is what should drive whether dues_overdue re-escalates, not a hard stop.
3. HIGH — No bank-account / payment-instructions surface anywhere
Evidence:
grep -rln "bankName|bankAccount|accountHolder|입금|계좌번호"acrosspackages/app/src/domain/entities/club.entity.tsandpackages/features/clubs/src→ 0 hits. TheClubentity has no bank-account field of any kind.- The member-side dues view's only guidance is a pinned card:
club-dues-screen.tsx:420-429renderslabels.memberView.contactAdminNote→ i18n'납부 관련 문의는 총무에게 직접 연락해주세요.'("contact your 총무 directly for payment questions") —settings.ts:895-901. session-payments-screen.tsx(the sibling per-session fee flow, scope B3/00298-00303) is unrelated and has zero 회비 mentions — confirmed the two payment concepts don't bleed into each other in code, but that also means dues doesn't inherit any of session-payment's structured flow (manual-rail attest/confirm,paymentMethodenum surfaced to the payer).
Impact: a member opens 회비, sees "미납 ₩30,000" in red, and the app gives them zero actionable information — no account number, no KakaoPay/Toss deep link, not even which of the 5 PAYMENT_METHODS (bank|cash|kakaopay|toss|other, already modeled in the domain entity, dues.entity.ts:16-17) the club prefers. Every single payment forces the pair into an out-of-app KakaoTalk conversation to even learn HOW to pay, which is exactly the workflow this feature is supposed to replace. For a 총무-first app benchmarked against Toss, on THE core money loop, this is the single biggest gap between "tracks dues" and "collects dues."
Fix shape: add a club-level duesPaymentInfo (bank name + account number + holder name, or a KakaoPay/Toss link) settable from club settings (admin-only), surfaced as a compact card on the member-side dues screen above the row list — reuses the existing paymentMethod enum for the label. Migration + entity + mapper + settings-screen field + member-view card, same shape as feedback_entity_migration_checklist.md.
4. HIGH — Ex-members' unpaid dues are immortal
Evidence:
dues.user_idreferencespublic.profiles(id)(00009_dues.sql:5), notclub_members— leaving a club (deactivating/removing theclub_membersrow) does not cascade to, filter, or waive that user'sduesrows in any migration.grep -n "dues" supabase/migrations/00320*.sql supabase/migrations/00322*.sql(the two most recent membership-lifecycle-hardening migrations, per project memoryproject_club_lifecycle_audit.md) → 0 hits — dues was never in scope for the leave/remove cascade audit.signals_tick_dues()(00108_signals_dues_triggers.sql:182-188) selectsFROM public.dues d JOIN public.clubs c ON c.id = d.club_id WHERE d.status = 'unpaid'— no join toclub_members, no active-membership check at all.get_club_admin_snapshot'soverdue_dues_count(00381_club_admin_snapshot.sql:97-103) has the same gap — counts any unpaid row for the club regardless of whether thatuser_idis still an active member.
Impact: a member who leaves a club with an unpaid or partially-paid balance keeps their dues row forever. They keep receiving escalating dues_overdue → dues_severely_overdue (critical) push notifications for a club they are no longer part of, with no way to stop it short of the 총무 manually finding and waiving that specific row (and even then, waiving doesn't resolve the signal — finding #1). The 총무's monthly dues list also carries the departed member's row indefinitely, inflating the "미납" count for a person who can no longer be asked to pay through any in-app channel.
Fix shape: on member removal/leave, either auto-transition that user's unresolved dues rows for the club to 'waived' (with a system note) or exclude inactive-member rows from signals_tick_dues and the admin-snapshot overdue count via a club_members.is_active join — same shape as the seat-release-on-leave fix in 00322.
5. MEDIUM-HIGH — Mid-month member join has no dues-generation path once the month's batch already exists
Evidence:
club-dues-screen.tsx:301—showGenerateCta = canManageDues && dues.length === 0. TheBottomCtaBand"이번 달 회비 생성" CTA is the only UI entry point tohandleGenerateDues, and it's gated on the entire month's dues list being empty.handleGenerateDues(club-dues-screen.tsx:238-268) itself is correctly written to be idempotent-safe (existingUserIdsfilter, so re-running it only inserts missing members) — but there is no button, row, or menu item that calls it oncedues.length > 0.
Impact: a club that generates dues on the 1st of the month, then gains a new member on the 15th, has no in-app way to create that member's dues row for the current month — ever, for that month. The correctly-idempotent batch logic is unreachable after first use. This is the direct edge case the audit asked about ("mid-month member join — prorated?") and the answer is worse than "not prorated" — it's "not generated at all."
Fix shape: surface a persistent (not just empty-state) "새 멤버 회비 등록" action — e.g. an AdminNavRow-style row or a small action inside SummaryCard — that calls the same handleGenerateDues for members missing from the current dues list, independent of whether the list is empty.
6. MEDIUM — Dues signal tap loses temporal context
Evidence:
signal-routes.ts:16-18—if (category === 'dues') return clubId ? routes.clubDues(clubId) : null;. The route factory (routes.ts:63) isclubDues: (clubId) => .../dues— no year/month param, even though every dues signal'spayloadcarriesyear/month(00108_signals_dues_triggers.sql:55-57etc.) andScreenContext.scopeeven declares an unusedduesIdfield (signal-resolver.ts:56).club-dues-screen.tsx:72-73—selectedMonthalways initializes togetCurrentMonth(), with no mechanism to receive a target month from navigation params.
Impact: tapping a dues_severely_overdue signal for last month's unpaid dues lands the user on this month's dues view (likely showing a different, possibly-empty list) — the deep link degrades to "the right screen, wrong data," requiring a manual back-arrow tap on MonthSelector to find the actual flagged row. Not a dead-end (unlike U2/U1's worse findings), but a real "did the tap even do anything?" moment on the single most urgent signal category (dues carries critical severity at day+7).
Fix shape: extend routes.clubDues to accept optional { year, month } query params (mirrors how routes.sessionPayments already threads sessionId), thread payload.year/payload.month from the signal through getSignalRoute, and read them in ClubDuesScreen's initial selectedMonth state.
7. MEDIUM — Currency-formatting drift within the same screen
Evidence:
dues-helpers.ts:22-24—formatAmount()(used by every row + the summary card, i.e. all persistent on-screen amounts) callst().common.won(amount)→common.ts:71:`${amount.toLocaleString('ko-KR')}원`— e.g."30,000원".club-dues-screen.tsx:57,253importsformatWonfromcreate-club-helpers.ts:191-193—value.toLocaleString('ko-KR')with no원suffix — e.g."30,000"— and uses it to build thegenerateDuesMessageconfirm-dialog text (club-dues-screen.tsx:253-257).
Impact: the same screen, in the same flow (tap 회비 생성 → see a confirm dialog reading "…30,000 회비를 12명에게 생성할까요?" with no 원 → tap confirm → see rows that all read "30,000원") shows two different number formats for the identical amount seconds apart. Minor on its own, but exactly the kind of drift the canon dimension ("currency formatting consistency — one util?") was checking for — there are in fact two live formatters doing the same job with different output.
Fix shape: drop formatWon from this call site; use formatAmount/t().common.won everywhere inside the dues feature (retain formatWon/parseWon for the create-club wizard's live-editable input field, where the no-suffix form is arguably intentional for an onChangeText round-trip).
8. MEDIUM — No bulk mark-paid; the core 총무 loop is strictly per-member
Evidence: DuesActionModal (dues-action-modals.tsx:16-83) is opened per-row via each DuesRow's kebab button (club-dues-screen.tsx:120-135, openAction). There is no multi-select mode on the FeedList, no "모두 납부 처리" action on SummaryCard or the header, and no batch mutation variant of useUpdateDuesStatus (use-update-dues.ts exposes only a single-row mutate).
Adversarial probe: marking 10 members paid = tap kebab (1) → tap "완납 처리" (2), repeated ×10 = 20 taps + 10 modal transitions, with each useUpdateDuesStatus call independently invalidating duesKeys.byClub/clubSummary/overdue (use-update-dues.ts:158-172) — correctly Protocol-C-scoped, so no perf issue, but no time saved either.
Impact: this directly works against the CLAUDE.md UX filter ("would Toss ship this?") for what the same doc calls the 총무's core loop. A 30-40 member club (a realistic size per this audit's own edge-case list) collecting monthly dues via bank-transfer screenshots means the 총무 does this exact 2-tap sequence 30-40 times every month with zero "am I done yet" affordance beyond watching the ProgressBar in SummaryCard creep up.
Fix shape: minimum viable version — a "모두 납부 처리" action on SummaryCard for the still-unpaid set (with a useConfirm guard, mirroring the waive pattern), or a long-press multi-select on DuesRow. A batch updateStatusBatch port method would avoid N sequential round-trips.
9. MEDIUM (dead code / docs) — settings.ts's entire signals object is unreachable, with drifted duplicate copy
Evidence:
packages/app/src/config/i18n/ko/settings.ts:230-369defines a top-levelsignals: { title, subtitle, entryLabel, entryDescription, category: {...}, messages: {...} }object — a full parallel structure topackages/app/src/config/i18n/ko/signals.ts'skoSignals(), which also returns a top-levelsignals: { category, messages }key.ko/index.ts:21-43builds the merged dictionary via a shallow spread:{ ...koSettings(), ...koSignals(), ... }withkoSettings()spread beforekoSignals(). Since both contribute a same-named top-levelsignalskey,koSignals()'s value (spread later) completely replaceskoSettings()'ssignalssubtree — no deep merge. Confirmed no live consumer readst().signals.title/.subtitle/.entryLabel/.entryDescriptionanywhere (grep -rnacrosspackages/featurespackages/app/src→ the only live consumer ist().signals.category, used innotification-center-screen.tsx:67, which both files happen to define compatibly).- The dead block's dues-related copy has materially different, and in one case factually wrong, wording vs the live copy it's shadowed by:
settings.ts:323-324(dead):'signal.dues.clearedByAdmin.title': '회비가 납부 완료로 처리됐어요'("processed as paid") — this is factually incorrect for a waiver (the member did NOT pay).signals.ts:120-121(live):'signal.dues.clearedByAdmin.title': '회비 면제'("dues exempted/waived") — correct.
Impact: currently inert (nothing renders the dead copy), so no user sees the wrong string today. But it's a live landmine: a future edit to "the dues notification copy" has better-than-even odds of landing in the dead settings.ts block first (it's alphabetically/contextually adjacent to the equally-dead dues: block, finding #10), and the edit would silently do nothing.
Fix shape: delete the dead signals: {...} object from settings.ts entirely (or, if title/subtitle/entryLabel/entryDescription are meant to back an actual signal-preferences settings screen that doesn't exist yet, rename the key to avoid the top-level collision, e.g. signalPreferences, and wire it to a real consumer).
10. LOW-MEDIUM (dead code) — a second orphaned dues i18n subtree describes unshipped features
Evidence: settings.ts:3-40 defines a top-level dues: {...} object (duesManagement, generateDues, markAsPaid, markAsPaidConfirm, duplicateDuesWarning: (count) => 이미 ${count}건의 회비가 있어요. 계속할까요?``, sendReminder: '납부 알림 보내기', paymentMethodBank/Cash/KakaoPay/Toss/Other, etc.). grep -rn for any of these property names as t().dues.* across packages/features/ packages/app/src → 0 consumers. The screen that actually ships uses t().clubDues.* (a differently-named, fully-populated sibling object at settings.ts:862), not t().dues.*.
Impact: low direct impact (inert), but notable for this audit specifically because the dead copy describes exactly two of the gaps found above by independent code-reading: a duplicate-generation warning (this audit's finding: no such warning exists — a second generate attempt just silently 0-inserts via the client-side filter, or hard-fails on the unique constraint if the cache is stale) and a "납부 알림 보내기" (send payment reminder) action (no reminder-push feature exists anywhere in the current dues signal set). Either this is stale copy from a prior design that never shipped, or it's forward-scaffolding for planned work that was never wired up — either way it's worth flagging as a signal of what the original design intended vs. what's live.
Fix shape: delete if genuinely superseded by clubDues; if it's intentional scaffolding for a planned duplicate-guard + reminder-send feature, move it under a name that doesn't collide/confuse (e.g. clubDuesPlanned) with a tracking note, per the project's own "no half-built screen, dead-end text" standard.
11. LOW — 0-dues clubs still show the full 회비 admin UI
Evidence: admin-tab.tsx:71-78 renders the 회비 AdminNavRow whenever canManageDues is true — no check on club.duesAmount > 0. club-dues-screen.tsx:238-268 (handleGenerateDues) has no guard either — amount: club?.duesAmount ?? 0 happily creates amount: 0 rows for every member, defaulting to status: 'unpaid'. Create-club step 5's own comment (create-club-step5.tsx:2) calls the whole dues section "skippable," confirming duesAmount === 0 is an intentional, supported "this club doesn't collect dues" state.
Impact: a free/no-dues club's 총무 who taps into 회비 (out of curiosity or muscle memory) sees a fully-functional-looking dues board, can tap "이번 달 회비 생성," and gets a roster of ₩0 "미납" badges that then need individually clearing — confusing busywork for a club that opted out of the feature entirely.
Fix shape: hide the 회비 AdminNavRow (and the member-view route) entirely when club.duesAmount === 0, or replace the CTA with an EmptyState pointing at club settings ("회비 금액을 설정하면 여기서 관리할 수 있어요") — the duesAmountNotSet/duesAmountNotSetDescription copy already sitting unused in the dead block (finding #10) is literally written for this exact state.
12. LOW — DuesPartialPaymentModal reinvents amount parsing
Evidence: dues-action-modals.tsx:103-104 — parseInt(value.replace(/[^0-9]/g, ''), 10) — duplicates create-club-helpers.ts:196-199's existing parseWon(), which does the same digit-strip-and-parse. Minor single-source-of-truth violation; moot in practice today since the parsed value is discarded regardless (U2 #2 / this doc's #2).
Fix shape: import and use parseWon once the partial-amount schema work (U2's fix shape) makes this value actually matter.
Going well (for balance — checked and found correct)
DuesStatusBadge(status-badges.tsx:162-168) is a fully canonicalXxxStatusBadgewrapper overBadgewith a variant map — no ad-hoc badge rendering found anywhere in the dues feature (COMP-2 compliant).MonthSelectorcorrectly disables forward navigation past the current month (isAtCurrent = value >= currentMonth,month-selector.tsx:21,45) — prevents generating/viewing future-month dues by accident; a real, deliberate edge-case guard.- Protocol C-scoped invalidation — both
useUpdateDuesStatus(use-update-dues.ts:152-176) anduseGenerateDues(use-update-dues.ts:189-201) narrow invalidation toduesKeys.byClub/clubSummary/overduefor the specific club+month rather than sweepingduesKeys.all, with an explicit comment calling out the "20 members marked paid in sequence ≠ 20 full-cache refetches" rationale. The optimistic patch (patchDuesInCachedValue) correctly fans out across both list-shaped and single-record-shaped cached query results. - DB-level duplicate-generation guard exists —
UNIQUE(club_id, user_id, year, month)(00009_dues.sql:14) makes a true duplicate row impossible regardless of any client-side race;handleGenerateDues's client-sideexistingUserIdsfilter (club-dues-screen.tsx:240-241) is correctly idempotent when re-invoked (the bug in finding #5 is that nothing re-invokes it, not that re-invoking it would misbehave). Year-boundary stepping (stepMonth,dues-helpers.ts:16-20) relies on JSDate's native month-overflow normalization — correctly rolls Dec ↔ Jan without special-casing. - Offline-queue wiring (Phase 14d) —
use-update-dues.ts:32-56registers adues:update-statusexecutor eagerly at module load and threadsidempotencyKeyend-to-end (isAlreadyAcceptedErrorreplay-safety check at line 111-114) — dues mark-paid survives a cold-launch replay correctly, ahead of several other mutation hooks per project memory. - Session-fee vs. club-dues separation is clean in code —
session-payments-screen.tsxhas zero 회비/dues references; the two systems don't leak into each other's screens or mutations despite sharing the "money owed" shape. The confusion risk this audit went looking for (dimension: "is the distinction clear in UI") wasn't found within either screen — it shows up instead as the bank-info gap (#3), which is a dues-specific absence, not a dues/session mix-up. - Destructive-action discipline —
handleWaiveroutes throughuseConfirmwith adestructivebutton style (club-dues-screen.tsx:198-223), consistent with the rest of the app's money/destructive-action convention.
Adversarial probes run (explicit list)
- 10-member bulk mark-paid → traced to per-row-only action modal, no batch primitive anywhere in the port/mutation/UI stack (#8).
- Mid-month join → traced
handleGenerateDues's gating condition; confirmed the CTA (its only trigger) disappears the moment any dues row exists for the month, independent of whether all current members have one (#5). - Member left with unpaid dues → traced
dues.user_id's FK target (profiles, not club_members), the signal cron's WHERE clause, and the admin-snapshot RPC's WHERE clause — none filter by active membership (#4). - 0-dues club → traced
duesAmountthroughAdminTab, the CTA gate, andhandleGenerateDues— no gate found anywhere (#11). - Duplicate-generation attempt → confirmed DB-level UNIQUE constraint backstops the client-side filter; a genuine race (two admins, stale cache) surfaces as a generic
duesUpdateFailedtoast, not a crash or partial insert (insert is single-statement, all-or-nothing) — acceptable, not filed as a separate finding. - Year boundary (Dec→Jan) →
stepMonth'snew Date(year, month-1+delta, 1)relies on JSDateoverflow normalization — verified correct, no bug. - Cross-surface overdue-count agreement → read all four live "overdue" computations side by side (
get_home_signals00103,get_club_admin_snapshot00381,signals_tick_dues00108,SummaryCard's status counts) — found four genuinely different definitions (status-set, time granularity, and scope all differ) (#2 top of ranking, detailed evidence below). - Signal tap destination → traced
getSignalRoute'sduesbranch androutes.clubDues's signature — confirmed no month param threads through despite the signal payload carryingyear/month(#6). - Currency format consistency → grepped every amount-formatting call site reachable from the dues screen — found the two-formatter split (#7).
- i18n dead-code sweep — triggered by the signal-copy investigation above; traced
ko()'s shallow-spread merge order and confirmed two full orphaned subtrees insettings.ts(#9, #10).
Detail — the four live "overdue" definitions (severity #2 top-ranked evidence)
| Surface | Source | Status set | Time granularity | Scope |
|---|---|---|---|---|
| Home admin-attention badge | get_home_signals (00103_get_home_signals_rpc.sql:60-66) | unpaid + partial | strictly-past calendar month (year<v_year OR (year=v_year AND month<v_month)) | all clubs the user belongs to, player-scoped |
| Club AdminTab snapshot strip | get_club_admin_snapshot (00381_club_admin_snapshot.sql:92-103) | unpaid only (comment explicitly excludes partial, "deliberately NOT replicated per the task spec") | strictly-past calendar month, same formula as above | one club, admin-scoped |
| Signal push/escalation cron | signals_tick_dues (00108_signals_dues_triggers.sql:169-265) | unpaid only | per-club dues_day-precise days-past (due_date = make_date(year, month, dues_day); escalates from day +1, i.e. can fire within the current calendar month | one row at a time, per-member push |
| Dues screen itself | SummaryCard (dues-summary-card.tsx:15-21) | separates paid/unpaid/partial as three buckets | no "overdue" concept at all — counts whatever status a row has for the selectedMonth, including the current month | one club, one selected month |
Concrete failure case: club has dues_day = 5. It's the 10th of the current month; a member's dues are unpaid. The signal cron already computed days_past = 5 on day 10 (≥1 → dues_overdue, high severity) and will hit days_past ≥ 7 (→ dues_severely_overdue, critical) on the 17th — all still within the same calendar month. Meanwhile get_club_admin_snapshot's overdue_dues_count filter (year=v_year AND month<v_month) excludes the current month categorically — the AdminTab strip shows "회비 미납 0건" the entire time the member is receiving critical push notifications for that exact row. The 총무 has no reason to open 회비 at all (the attention strip is the product's designed "start here" surface, per docs/canon R2-2), while the member's phone is already escalating to critical severity.
Canon violations summary
- CLAUDE.md's standing "no half-built screen, dead-end text" complaint — the waive action (#1) and mid-month generation gap (#5) both present a fully-working-looking UI that quietly fails to do its job, the same shape as U2's partial-payment bug.
- Single source of truth — two live currency formatters (#7), two parse-amount implementations (#12), two orphaned-but-once-real i18n subtrees shadowing live ones with drifted copy (#9, #10).
- Cross-surface consistency (implicit, R2-2's own design intent — the AdminTab snapshot strip is explicitly the "start here" attention surface) — violated hard by the four-way overdue-definition split (#2).
- Everything else audited (status-badge canon, destructive-action
useConfirmusage, Protocol C invalidation scoping, offline-queue wiring, DB-level duplicate guard) was found compliant.
Improvement candidates (design-spec seeds for the synthesis pass)
- Broaden the dues signal-resolution guard from
NEW.status='paid'toNEW.status IN ('paid','waived','partial'), plus a newdues_waivedsignal type — closes #1 and #2, small migration + entity + i18n change. - Bank-account / payment-instructions field on the club entity, surfaced on the member-side dues screen — closes #3, standard entity-migration-checklist shape.
- Active-membership filter on
signals_tick_duesandget_club_admin_snapshot's overdue count (joinclub_members+is_active) — closes #4, mirrors the00322seat-release pattern. - Persistent "add missing member" dues action, not gated on
dues.length === 0— closes #5. - Thread
year/monththrough the dues signal route — closes #6, smallroutes.ts+signal-routes.tschange. - Unify overdue definitions — at minimum, make
get_club_admin_snapshotuse the samedues_day-precise due-date logic assignals_tick_duesinstead of a blunt calendar-month boundary — closes the sharpest edge of #2 (the AdminTab strip silently lying about the current month). - Bulk mark-paid on
SummaryCard— closes #8. - i18n cleanup pass — delete or rename the two orphaned
settings.tssubtrees (#9, #10); mechanical, ~180 lines removed, zero behavior change since neither is reachable today.