fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants

- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent fba00a10ad
commit 9a12a2d886
27 changed files with 1206 additions and 226 deletions
+29 -20
View File
@@ -240,35 +240,44 @@ describe('buildCashTillPaymentBody', () => {
});
describe('cashChargeBasePence', () => {
// Concrete arithmetic pinned to the FIX-1 scenario: booking £100, pending
// 10% campaign preview (£10), £10 loyalty redemption, £100 cash tender with
// the keep-change-as-tip checkbox on. netTotal = £90 (campaign subtracted),
// so the current charge base of £85 (totalDue loyalty) understates the
// backend's remaining basis and absorbs the tip.
it('restores the pending campaign credit into the charge base (tip carve basis)', () => {
// totalDuePence = £90 net (campaign subtracted, pre-loyalty)
expect(cashChargeBasePence(9000, 1000, 1000)).toBe(9000);
// FIX-1/FIX-2: The charge base is the FULL totalDuePence — neither the
// campaign credit nor the loyalty discount is subtracted. The backend
// applies both at completion via separate discount rows, and the tip carve
// (`amount remaining`) uses GetBookingRemainingBalancePence which does
// NOT account for pending discounts. Subtracting them would undercharge
// the booking and absorb the tip into booking credit.
it('charges the full total due — campaign and loyalty are applied server-side', () => {
// Booking £100 net (campaign already subtracted), £10 campaign credit,
// £10 loyalty redemption → charge base = £100 (full totalDuePence)
expect(cashChargeBasePence(10000, 1000, 1000)).toBe(10000);
});
it('keeps the plain net total when no campaign is eligible', () => {
// totalDue = £90 (no campaign), loyalty £10 → base = £80 = the net obligation
expect(cashChargeBasePence(9000, 0, 1000)).toBe(8000);
it('ignores campaignPence and loyaltyPence — always returns totalDuePence', () => {
// totalDue = £90 (no campaign), loyalty £10 → base = £90, not £80
expect(cashChargeBasePence(9000, 0, 1000)).toBe(9000);
});
it('never goes below zero (fully covered by discounts + loyalty)', () => {
expect(cashChargeBasePence(1000, 0, 5000)).toBe(0);
it('never goes below zero', () => {
expect(cashChargeBasePence(0, 0, 0)).toBe(0);
expect(cashChargeBasePence(-100, 0, 0)).toBe(0);
});
it('the folded tip body uses the base, so UI tip == backend-recorded tip', () => {
// Booking £100, campaign £10, loyalty £10: base = 9000 (100 20 + 10).
// Tender £100 → tip £10 → amount £100. The backend carves against the
// full £100 remaining, so it records £0 tip — matching the UI claim
// that only the amount above the charge base is a tip.
const basePence = cashChargeBasePence(9000, 1000, 1000);
const tipPence = 10000 - basePence;
expect(buildCashTillPaymentBody(basePence, tipPence).amount).toBe(10000);
// Booking £100 net, campaign £10, loyalty £10: base = 10000.
// Tender £110 → tip £10 → amount £110. The backend carves against
// the full £100 remaining (no discount deduction), so it records
// £10 tip — matching the UI claim.
const basePence = cashChargeBasePence(10000, 1000, 1000);
const tipPence = 11000 - basePence;
expect(buildCashTillPaymentBody(basePence, tipPence).amount).toBe(11000);
expect(tipPence).toBe(1000);
});
it('campaignPence and loyaltyPence are accepted but ignored (call-site compat)', () => {
// The parameters exist for call-site compatibility — the calling
// modals still compute them for display. The arithmetic ignores them.
expect(cashChargeBasePence(5000, 9999, 9999)).toBe(5000);
});
});
describe('payment failure classification', () => {