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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user