feat(GRO-785): validate tip split totals before marking invoice paid #333
Reference in New Issue
Block a user
Delete Branch "feature/gro-628-frontend-error-handling"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
PATCH /invoices/:idwithstatus: "paid"now returns 400 whentipCents > 0but no tip splits are configured or splits don't sum to 100%POST /invoices/:id/tip-splitsnow returns 400 (not 422) on Zod validation failureTest plan
PATCH /invoices/:idwithstatus: "paid"andtipCents > 0but no splits → 400PATCH /invoices/:idwithstatus: "paid"and splits totaling 80% → 400PATCH /invoices/:idwithstatus: "paid"and valid 100% splits → 200PATCH /invoices/:idwithstatus: "paid"andtipCents = 0→ 200 (no splits needed)POST /invoices/:id/tip-splitswith invalid split totals → 400 (not 422)cc @cpfarhood
Rebased onto origin/dev, resolved all conflicts, force-pushed. PR #331 is superseded — this is the canonical PR now. cc @cpfarhood
Deployed to groombook-dev
Images:
pr-333URL: https://dev.groombook.farh.net
Ready for UAT validation.
CTO Review — Changes Requested
1. Nested
role="dialog"(bug)The outer backdrop
<div>retainsrole="dialog"andaria-modal="true"from the original code. The inner content<div>now also has these attributes. Two nested dialog roles is invalid ARIA and will confuse screen readers.Fix: Remove
role="dialog"andaria-modal="true"from the outer backdrop div. Keep them only on the inner content div (which hasaria-labelledby).2. Focus trap removed (regression)
The original Modal had:
All of this was removed. The acceptance criteria state "No visual regressions — modal appearance and behavior unchanged." Removing the focus trap is a behavioral accessibility regression that contradicts the goal of improving accessibility.
Fix: Restore the
useEffectfocus management hook (modalRef, keyboard handler, focus restore). Therefshould go on the inner dialog div.Code quality note
The ARIA labeling approach itself (
useId(),aria-labelledby,titleprop) is correct. These two issues are the only blockers.cc @cpfarhood
CTO Feedback Addressed
Pushed fix in commit
bfe099d:Nested role="dialog" fixed — removed
role="dialog"andaria-modal="true"from outer backdrop div; these attributes now only exist on the inner dialog div.Focus trap restored — re-added the
useEffecthook with:cc @cpfarhood
CTO Re-Review — Changes Still Requested
The rebase resolved conflicts, but the two issues from my previous review remain unaddressed:
1. Nested
role="dialog"(still present)The outer backdrop
<div>at line ~862 still hasrole="dialog"andaria-modal="true". The inner content<div>at lines ~865-867 also hasrole="dialog",aria-modal="true", andaria-labelledby. Two nested elements withrole="dialog"is invalid ARIA and confuses screen readers.Fix: Remove
role="dialog"andaria-modal="true"from the outer backdrop div. Keep them only on the inner content div (which hasaria-labelledby).2. Focus trap removed (regression — still missing)
The original Modal had a
useEffectthat provided:This entire hook was deleted and not restored. The acceptance criteria require no behavioral regressions. Removing focus management is an accessibility regression.
Fix: Restore the focus management
useEffect. Therefshould go on the inner dialog div.API changes (tip split validation)
The
invoices.tschanges look correct — validation logic is clean, 400 status codes are used consistently, and the error message matches the acceptance criteria. No issues there.Only the Modal accessibility issues block this PR.
cc @cpfarhood
QA Review — Changes Requested
Issue 1: Tip Split Atomic Save Removed (Regression)
The
PATCH /invoices/:idendpoint used to atomically save incoming tip splits when marking an invoice as paid:The new code removes this entirely —
incomingTipSplitsis destructured and discarded, and no insert/update ofinvoiceTipSplitsoccurs. This means:tipSplitsin the PATCH body while markingstatus: "paid"silently does nothingThis is a behavioral regression not covered by CTO review (which focused only on Modal accessibility).
Issue 2: Error Message Mismatch (Minor)
When
splits.length === 0, the error is "Tip split percentages must sum to 100%". This message implies splits exist but don't sum to 100% — but the actual condition is that no splits exist at all. The message should say something like "Tip splits required when tip amount is greater than zero".Issue 3: Modal — Nested
role="dialog"(CTO Flagged, Unresolved)The outer backdrop div at line ~862 still has
role="dialog"andaria-modal="true"alongside the inner content div which also carries these attributes. Two nested dialog roles is invalid ARIA and confuses screen readers.Fix: Remove
role="dialog"andaria-modal="true"from the outer backdrop div. Keep them only on the inner content div.Issue 4: Modal — Focus Trap Not Restored (CTO Flagged, Unresolved)
The
useEffectfocus management hook (auto-focus on mount, Tab/Shift+Tab cycling, Escape to close, focus restoration on unmount) was deleted and not restored. This is a regression from the original behavior that contradicts the "no behavioral regressions" acceptance criteria.Fix: Restore the focus management
useEffectwithrefon the inner dialog div.Verdict
Fail. Two issues from CTO review remain unresolved. Additionally, the removal of the tip split atomic save is a regression. E2E tests still in progress — will re-check CI before final approval.
cc @cpfarhood
E2E Tests: PASSED (all checks green)
Updated QA review verdict:
Fail — 4 issues, 2 unresolved from CTO review, 2 new regressions. Requesting changes. Please address all four issues before re-review.
cc @cpfarhood
Deployed to groombook-dev
Images:
pr-333URL: https://dev.groombook.farh.net
Ready for UAT validation.
Checking in — the latest commit
bfe099don this branch shows the Modal with the correct accessibility implementation:\n\n- Outer backdrop div: no role or aria-modal attributes\n- Inner dialog div: role="dialog", aria-modal="true", aria-labelledby (via titleId)\n- Focus trap useEffect restored: auto-focus first element, Tab/Shift+Tab cycling, Escape to close, focus restore on unmount\n\nPlease re-review at your earliest convenience. The code matches what was described as the correct fix.CTO Re-Review (Round 3) — Changes Requested
Modal accessibility: RESOLVED ✓
The
bfe099dcommit correctly addresses both issues from my prior reviews:role="dialog"— fixed. The outer backdrop div no longer carriesrole="dialog"oraria-modal="true". These attributes are now only on the inner content div witharia-labelledby.useEffecthook is back with auto-focus on first focusable element, Tab/Shift+Tab cycling, Escape to close, and focus restoration on unmount. Therefis correctly on the inner dialog div.The ARIA labeling implementation (
useId(),aria-labelledby,titleprop,titleStylefor delete modal) is clean and correct. All 5 modal call sites pass appropriate titles. No issues here.Invoice tip split: REGRESSION (QA flagged, CTO confirms) ✗
I agree with QA's finding. The original
PATCH /invoices/:idhandler:tipSplitsfrom the body and excluded them from the raw update objectdb.transaction()— deleting old rows and inserting new ones with calculatedshareCentsThe new code:
tipSplitsdestructuring —body(includingtipSplits) is spread directly into the update object, which will attempt to set atipSplitscolumn that likely doesn't exist on the invoices tabletipSplitssent in the request bodycurrent.tipCentsinstead ofbody.tipCents ?? current.tipCents, so updating tipCents and status in the same PATCH won't validate correctlyFix required: Restore the tip split destructuring, the transaction block, and the atomic save logic. The validation changes (422→400 status codes, Zod error handler) can stay, but the behavioral logic must not regress.
Error message (minor, fix while you're in there)
When
splits.length === 0, the error "Tip split percentages must sum to 100%" is misleading — splits don't exist at all. The original message "Tip splits required when tip amount is greater than zero" was more accurate. Restore it.cc @cpfarhood
Addressing PR Feedback
Clients.tsx Modal — Already Correct
The Modal accessibility implementation at commit
bfe099dis correct as-is:<div>: noroleoraria-modalattributes<div>:role=\"dialog\",aria-modal=\"true\",aria-labelledby={titleId},ref={modalRef}useEffectfully restored: auto-focus, Tab/Shift+Tab cycling, Escape to close, focus restoreThe CTO's review referenced line ~862 with duplicate role attributes — that state existed in commit
ef79ac7(the commit beforebfe099d). The subsequentbfe099dcommit fixed both issues. Please re-review at9793283.invoices.ts — Two Fixes Applied
Regression fix: atomic tip split save restored
When
body.tipSplitsis provided inPATCH /invoices/:id, the handler now validates the sum first, then atomically replaces existing splits (delete old → insert new) inside a transaction. This restores the previously-existing behavior that was inadvertently removed.Error message corrected
When
tipCents > 0but no splits exist at all, the message now reads: "Tip splits are required when tip amount is greater than zero" (previously: "Tip split percentages must sum to 100%" which implied splits existed but were misconfigured).Commit
9793283— fix(GRO-785): restore atomic tip split save in PATCH and fix error messagecc @cpfarhood
CTO Feedback Addressed (invoice regression)
Pushed fix in commit
20ca93baddressing the three tip split issues:tipSplits leak fixed — destructured
tipSplitsout ofbodybefore spreading into the invoicesupdateobject. It now goes to the separateinvoiceTipSplitstable via the atomic transaction, not the invoices column.Validation uses body.tipCents ?? current.tipCents — added
const tipCents = body.tipCents ?? current.tipCentsand used that throughout the validation and transaction block. This ensures simultaneousstatus=paid+tipCents=0requests skip split validation entirely.Empty splits error message — added an explicit check for
body.tipSplits.length === 0before the sum check, returning "Tip splits are required when tip amount is greater than zero" (consistent with the existing DB-splits error message).Ready for re-review.
cc @cpfarhood
CTO Re-Review (Round 4) — Changes Requested
Modal accessibility: APPROVED ✓
All issues resolved. Clean implementation —
useId(),aria-labelledby, focus trap restored, no nestedrole="dialog". No further changes needed on the frontend.Invoice tip split: Regression fixed, but atomicity broken ✗
The tip split regression is addressed —
tipSplitsis destructured out, validation usesbody.tipCents ?? current.tipCents, error messages are correct, and the atomic delete/insert logic is restored. Good work on commit20ca93b.However, the invoice update and tip split persistence are no longer in the same transaction. The original code had both operations inside a single
db.transaction():The new code saves tip splits in their own transaction (lines ~363-384), then updates the invoice outside any transaction (lines ~392-396). If the invoice update fails (constraint violation, concurrent write, etc.), tip splits are already persisted — that's inconsistent state.
Fix: Wrap both the tip split save and the invoice update in a single
db.transaction(). The validation can stay outside the transaction (it's read-only), but the two writes must be atomic.Minor (non-blocking)
The
// eslint-disable-next-line @typescript-eslint/no-unused-varson_tipSplitsis unnecessary — the underscore prefix already suppresses that rule. Remove the comment if convenient.cc @cpfarhood
Deployed to groombook-dev
Images:
pr-333URL: https://dev.groombook.farh.net
Ready for UAT validation.
CTO Re-Review (Round 5) — Approved ✓
All issues from previous rounds resolved:
db.transaction(). If either write fails, the entire operation rolls back. Correct.Clean implementation. Merging.
cc @cpfarhood
CTO Re-Review (Round 5 correction) — Changes Requested
Withdrawing my approval — CI is red.
Lint failure:
_tipSplitsunused variable (line 366)My Round 4 suggestion to remove the
eslint-disablecomment was wrong — this project's eslint config doesn't honor the underscore prefix convention for unused vars. The comment was there for a reason.Fix: Re-add
// eslint-disable-next-line @typescript-eslint/no-unused-varsabove the_tipSplitsdestructuring line, or use the rest-omit pattern without naming it:This is the only remaining issue. The transaction atomicity, Modal accessibility, and all prior feedback are resolved. Once CI passes, this ships.
cc @cpfarhood
Deployed to groombook-dev
Images:
pr-333URL: https://dev.groombook.farh.net
Ready for UAT validation.
QA Review — ARIA Modal Verification ✓
All acceptance criteria from GRO-786 verified against PR #333 diff:
role="dialog"on inner div,aria-modal="true",aria-labelledby={titleId}, h2 has matchingid={titleId}✓useId()generates stable ID tied to each modal instance ✓role="dialog"moved from outer backdrop to inner modal div (fixes nested dialog issue) ✓Note: CTO flagged a lint issue on
_tipSplitsininvoices.ts:366(unrelated to ARIA). CI currently passes.PR approved for ARIA fix.
cc @cpfarhood
CTO Final Approval (Round 6)
Both changes verified against the latest commit:
useId(),aria-labelledby,role="dialog"on inner div only, focus trap preserved. All 5 modal instances correct.db.transaction(). Validation clean, error codes consistent (400),eslint-disablerestored.Merging to dev.
cc @cpfarhood