Skip to content

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

#FindingSeverity
1"면제" (waive) writes no audit trail, never notifies the member, and never resolves their outstanding overdue signalsCRITICAL
2Four live, mutually-inconsistent definitions of "overdue" across four surfacesCRITICAL
3No bank-account / payment-instructions surface anywhere — member-side dues screen dead-ends at "contact your 총무"HIGH
4Ex-members' unpaid dues are immortal — no membership-active filter anywhere in the dues/signals pipelineHIGH
5Mid-month member join has no dues-generation path once the month's batch already existsMEDIUM-HIGH
6Dues signal tap loses temporal context — always routes to the CURRENT month, not the signal's monthMEDIUM
7Currency-formatting drift: formatWon() vs t().common.won() disagree on the suffix, same screenMEDIUM
8No bulk mark-paid — 2 taps × N members, zero batch affordanceMEDIUM
9Dead i18n subtree: settings.ts's entire signals object is shadowed and unreachable, with drifted duplicate copyMEDIUM (docs/dead-code)
10Second orphaned i18n subtree settings.ts:dues describes unshipped features (duplicate-warning, send-reminder)LOW-MEDIUM (dead code)
110-dues clubs still show the full 회비 UI, generating meaningless ₩0 "미납" rowsLOW
12DuesPartialPaymentModal 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 writes cleared_by/cleared_atif (input.status === 'paid'). For status: 'waived', clearedByUserId is silently dropped — the columns 00120 built specifically for "who cleared this row" audit purposes stay whatever they were (usually NULL).
  • The client-side optimistic patch mirrors the same gap: use-update-dues.ts:231-241 (mergeDuesPatch) only sets clearedBy/clearedAt if (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 full SignalType union for dues is dues_assigned | dues_due_soon | dues_overdue | dues_severely_overdue | dues_paid | dues_cleared_by_admin | dues_refund_issued. There is no dues_waived type at all.
  • signals_on_dues_update() (latest def: 00385_alert_model_wiring.sql:853-919, superseding 00120/00194) only branches inside IF 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 four signals_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 rows WHERE 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. a dues_severely_overdue, critical severity, no expires_at set — 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|입금|계좌번호" across packages/app/src/domain/entities/club.entity.ts and packages/features/clubs/src0 hits. The Club entity 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-429 renders labels.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, paymentMethod enum 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_id references public.profiles(id) (00009_dues.sql:5), not club_members — leaving a club (deactivating/removing the club_members row) does not cascade to, filter, or waive that user's dues rows in any migration. grep -n "dues" supabase/migrations/00320*.sql supabase/migrations/00322*.sql (the two most recent membership-lifecycle-hardening migrations, per project memory project_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) selects FROM public.dues d JOIN public.clubs c ON c.id = d.club_id WHERE d.status = 'unpaid'no join to club_members, no active-membership check at all.
  • get_club_admin_snapshot's overdue_dues_count (00381_club_admin_snapshot.sql:97-103) has the same gap — counts any unpaid row for the club regardless of whether that user_id is 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_overduedues_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:301showGenerateCta = canManageDues && dues.length === 0. The BottomCtaBand "이번 달 회비 생성" CTA is the only UI entry point to handleGenerateDues, 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 (existingUserIds filter, so re-running it only inserts missing members) — but there is no button, row, or menu item that calls it once dues.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-18if (category === 'dues') return clubId ? routes.clubDues(clubId) : null;. The route factory (routes.ts:63) is clubDues: (clubId) => .../duesno year/month param, even though every dues signal's payload carries year/month (00108_signals_dues_triggers.sql:55-57 etc.) and ScreenContext.scope even declares an unused duesId field (signal-resolver.ts:56).
  • club-dues-screen.tsx:72-73selectedMonth always initializes to getCurrentMonth(), 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-24formatAmount() (used by every row + the summary card, i.e. all persistent on-screen amounts) calls t().common.won(amount)common.ts:71: `${amount.toLocaleString('ko-KR')}원` — e.g. "30,000원".
  • club-dues-screen.tsx:57,253 imports formatWon from create-club-helpers.ts:191-193value.toLocaleString('ko-KR') with no suffix — e.g. "30,000" — and uses it to build the generateDuesMessage confirm-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-369 defines a top-level signals: { title, subtitle, entryLabel, entryDescription, category: {...}, messages: {...} } object — a full parallel structure to packages/app/src/config/i18n/ko/signals.ts's koSignals(), which also returns a top-level signals: { category, messages } key.
  • ko/index.ts:21-43 builds the merged dictionary via a shallow spread: { ...koSettings(), ...koSignals(), ... } with koSettings() spread before koSignals(). Since both contribute a same-named top-level signals key, koSignals()'s value (spread later) completely replaceskoSettings()'s signals subtree — no deep merge. Confirmed no live consumer reads t().signals.title/.subtitle/.entryLabel/ .entryDescription anywhere (grep -rn across packages/featurespackages/app/src → the only live consumer is t().signals.category, used in notification-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/src0 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-104parseInt(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 canonical XxxStatusBadge wrapper over Badge with a variant map — no ad-hoc badge rendering found anywhere in the dues feature (COMP-2 compliant).
  • MonthSelector correctly 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) and useGenerateDues (use-update-dues.ts:189-201) narrow invalidation to duesKeys.byClub/clubSummary/overdue for the specific club+month rather than sweeping duesKeys.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 existsUNIQUE(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-side existingUserIds filter (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 JS Date's native month-overflow normalization — correctly rolls Dec ↔ Jan without special-casing.
  • Offline-queue wiring (Phase 14d)use-update-dues.ts:32-56 registers a dues:update-status executor eagerly at module load and threads idempotencyKey end-to-end (isAlreadyAcceptedError replay-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 codesession-payments-screen.tsx has 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 disciplinehandleWaive routes through useConfirm with a destructive button style (club-dues-screen.tsx:198-223), consistent with the rest of the app's money/destructive-action convention.

Adversarial probes run (explicit list)

  1. 10-member bulk mark-paid → traced to per-row-only action modal, no batch primitive anywhere in the port/mutation/UI stack (#8).
  2. 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).
  3. 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).
  4. 0-dues club → traced duesAmount through AdminTab, the CTA gate, and handleGenerateDues — no gate found anywhere (#11).
  5. Duplicate-generation attempt → confirmed DB-level UNIQUE constraint backstops the client-side filter; a genuine race (two admins, stale cache) surfaces as a generic duesUpdateFailed toast, not a crash or partial insert (insert is single-statement, all-or-nothing) — acceptable, not filed as a separate finding.
  6. Year boundary (Dec→Jan)stepMonth's new Date(year, month-1+delta, 1) relies on JS Date overflow normalization — verified correct, no bug.
  7. Cross-surface overdue-count agreement → read all four live "overdue" computations side by side (get_home_signals 00103, get_club_admin_snapshot 00381, signals_tick_dues 00108, SummaryCard's status counts) — found four genuinely different definitions (status-set, time granularity, and scope all differ) (#2 top of ranking, detailed evidence below).
  8. Signal tap destination → traced getSignalRoute's dues branch and routes.clubDues's signature — confirmed no month param threads through despite the signal payload carrying year/month (#6).
  9. Currency format consistency → grepped every amount-formatting call site reachable from the dues screen — found the two-formatter split (#7).
  10. i18n dead-code sweep — triggered by the signal-copy investigation above; traced ko()'s shallow-spread merge order and confirmed two full orphaned subtrees in settings.ts (#9, #10).

Detail — the four live "overdue" definitions (severity #2 top-ranked evidence)

SurfaceSourceStatus setTime granularityScope
Home admin-attention badgeget_home_signals (00103_get_home_signals_rpc.sql:60-66)unpaid + partialstrictly-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 stripget_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 aboveone club, admin-scoped
Signal push/escalation cronsignals_tick_dues (00108_signals_dues_triggers.sql:169-265)unpaid onlyper-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 monthone row at a time, per-member push
Dues screen itselfSummaryCard (dues-summary-card.tsx:15-21)separates paid/unpaid/partial as three bucketsno "overdue" concept at all — counts whatever status a row has for the selectedMonth, including the current monthone 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 useConfirm usage, Protocol C invalidation scoping, offline-queue wiring, DB-level duplicate guard) was found compliant.

Improvement candidates (design-spec seeds for the synthesis pass)

  1. Broaden the dues signal-resolution guard from NEW.status='paid' to NEW.status IN ('paid','waived','partial'), plus a new dues_waived signal type — closes #1 and #2, small migration + entity + i18n change.
  2. Bank-account / payment-instructions field on the club entity, surfaced on the member-side dues screen — closes #3, standard entity-migration-checklist shape.
  3. Active-membership filter on signals_tick_dues and get_club_admin_snapshot's overdue count (join club_members + is_active) — closes #4, mirrors the 00322 seat-release pattern.
  4. Persistent "add missing member" dues action, not gated on dues.length === 0 — closes #5.
  5. Thread year/month through the dues signal route — closes #6, small routes.ts + signal-routes.ts change.
  6. Unify overdue definitions — at minimum, make get_club_admin_snapshot use the same dues_day-precise due-date logic as signals_tick_dues instead of a blunt calendar-month boundary — closes the sharpest edge of #2 (the AdminTab strip silently lying about the current month).
  7. Bulk mark-paid on SummaryCard — closes #8.
  8. i18n cleanup pass — delete or rename the two orphaned settings.ts subtrees (#9, #10); mechanical, ~180 lines removed, zero behavior change since neither is reachable today.

Markdown remains the source of truth. Run yarn docs:check before handoff.