fix: payments money-safety round-2 — webhook booking gate + M2 stranded-charge refund, sync-path status guards, gift-card cancel re-issue reconcile, till-sweep clawback lock, sweep VAT rescue, refund credit routing

- webhook payment.updated now re-reads the booking FOR UPDATE: non-payable booking -> payment failed + stranded-charge refund row + flood-capped alert; payable booking -> full completion side-effects; gift-card rows stay pending (C6 same-key retry); unknown events -> 503 so Square retries
- sync-path completion flips (saved-card, tip, online) guarded AND status='pending' + post-flip re-read; postChargeRecheck failed-mark guarded — no webhook-first double-processing, no phantom split rows
- CancelGiftCard resume reconciles ALL Square refunds (pending blocks re-issue; COMPLETED sum >= entitlement resolves+neutralizes; else re-issues only the difference under a fresh key) — closes double-refund
- sweep till_sales fail/clawback takes crussell:till:<key> lock + post-lock status re-read; recordUntrackedTillSalePayment applies VAT; rescue align clears VAT fields before re-apply (split-accurate VAT, tip rows stay VAT-free)
- refunds: chargeAggKey widened, redeemed-card refunds route to user_giftcard_balances, guest cash refunds recorded failed + notification
- handlers: tip split-records excluded from VAT loop, splitIdempotencyKey hashed, cross-booking key reuse 409, refunded payments excluded from existingCount, SCA-mint exemption for save_card, non-COMPLETED results routed

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 01b20b4420
commit 77317e4a45
9 changed files with 1947 additions and 149 deletions
+54 -1
View File
@@ -927,6 +927,18 @@ CREATE TABLE admin_notifications (
reason admin_notification_reason NOT NULL,
booking_id CHAR(12) REFERENCES bookings(id),
user_id CHAR(12) REFERENCES users(id),
-- Money-critical event detail (FIX 3b): populated by the
-- 'critical_payment_log' / 'refund_failed' insert sites (Square webhooks,
-- sweep, account-erasure, refunds) so the admin notifications page can
-- render WHAT happened without opening the CRITICAL logs. All three are
-- nullable so the routine insert sites (new_booking, pending_booking, ...)
-- keep working unchanged. Column contract: amount = the money amount
-- involved, square_id = the Square payment/dispute/checkout identifier,
-- description = a short human-readable detail line. See the adminnotify
-- package doc for the exact per-site mapping.
amount NUMERIC(10,2),
square_id TEXT,
description TEXT,
acknowledged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -934,6 +946,21 @@ CREATE TABLE admin_notifications (
CREATE INDEX idx_admin_notifications_reason ON admin_notifications(reason);
CREATE INDEX idx_admin_notifications_acknowledged_at ON admin_notifications(acknowledged_at);
-- Flood-cap suppression counter (adminnotify.MaxUnacknowledgedCriticalLogs):
-- one row per capped reason, counting how many alerts were dropped while that
-- reason's unacknowledged admin_notifications queue sat at the cap. Written by
-- CriticalLogsCapExceeded at every insert site (the single choke point, so no
-- insert site needs to change) and read by GET /api/admin/notifications as the
-- operator-facing "suppressed this cycle" count — only reasons whose queue is
-- STILL at the cap are reported, so the count naturally resets once the
-- operator acknowledges the queue down.
CREATE TABLE admin_notification_suppressions (
reason admin_notification_reason PRIMARY KEY,
suppressed_count INT NOT NULL DEFAULT 0,
first_suppressed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_suppressed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User notification preferences for future user notification system
CREATE TABLE user_notification_preferences (
id CHAR(12) PRIMARY KEY DEFAULT generate_user_notification_preferences_id(),
@@ -1965,7 +1992,8 @@ BEGIN
is_vat_applicable = TRUE,
updated_at = NOW()
WHERE id = payment_id
AND vat_amount IS NULL;
AND vat_amount IS NULL
AND payment_type != 'tip';
END;
$$ LANGUAGE plpgsql;
@@ -2757,3 +2785,28 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens (expires_at);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id ON refresh_tokens (family_id);
-- ============================================================================
-- Durable S3/R2 profile-picture deletion outbox (GDPR erasure completeness)
-- ============================================================================
--
-- DeleteAccountHandler persists one row here INSIDE its anonymization
-- transaction (before it commits), mirroring the Square erasure outbox (Fault
-- A1 → A2): the object-store deletion of the user's profile photo (PII) must
-- survive a process crash, or the photo would be retained indefinitely. The
-- async cleanup goroutine is the primary drain (deleting the row once the
-- object is confirmed gone); the retry-s3-deletions job
-- (internal/jobs/cleanup.go) is the crash safety net that completes any
-- deletion the goroutine could not finish. Rows are deleted on success; a
-- failed deletion stays in place and is retried on every job run.
CREATE TABLE IF NOT EXISTS pending_s3_deletions (
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('pending_s3_deletions'),
user_id CHAR(12), -- erased/anonymized user (no FK: guest users are deleted)
bucket TEXT NOT NULL, -- S3/R2 bucket holding the object
object_key TEXT NOT NULL, -- object key (e.g. profiles/<userID>.jpg)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
attempts INT NOT NULL DEFAULT 0, -- retry attempts so far (operator visibility)
last_error TEXT -- most recent deletion error (operator visibility)
);
CREATE INDEX IF NOT EXISTS idx_pending_s3_deletions_created_at ON pending_s3_deletions (created_at);