docs: round-2 loop-B changes — B1 webhook parent-resolve, APPROVED refund semantics, 2FA cooldown/StateFor, adminnotify flood cap, admin audit coverage, deposit POLICY consts
Payments doc + Technical Manual + README + Overview + Feature Catalog updated to the post-79b9ffb state, each claim verified against the code: - Ch7 sweep: webhook COMPLETED promotion resolves the B1 parent (re-poll parity); b1_attempts cap + fail-immediate-on-refund-error; till-sale funding trace - Ch6 refunds: APPROVED is non-terminal on the event-driven webhook path (FAILED can still demote); COMPLETED is the only terminal-completed; synchronous blocking-APPROVED override; over-refund guard semantics - Ch15 2FA: mint cooldown survives the gate verify (cleared on terminal success); StateFor saturation returns an immutable permanently-locked state (no-op LastMintAt writes, in-window counters never evicted) - Technical Manual: adminnotify.MaxUnacknowledgedCriticalLogs=100 flood cap at all insert sites + operator acknowledge-to-re-arm action; 2FA reissue-fail alert per-issue capped; /login+/register shared 20-slot bcrypt semaphore; progressive 429 only at top tier; admin audit coverage expanded (cash/gift-card/guest/ cancellation/reschedule-fee/transfer/clawback) - README: deposit POLICY constants single-source (0.5/0.2)
This commit is contained in:
@@ -6,7 +6,7 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL
|
|||||||
|
|
||||||
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 26 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours.
|
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 26 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours.
|
||||||
|
|
||||||
**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — new-card entry falls back to `CardEntryUnavailable` only when neither mock mode nor Square credentials are configured). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash till sales record the gift-card value and are marked completed, with no tendered/change fields. Any change or overpayment is handled manually by the admin at the counter. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A bounded PostgreSQL advisory try-lock (`pg_try_advisory_lock`, ~30 × 100ms ≈ 3s bound) serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour so a never-polled checkout cannot complete into an invisible, untracked charge.
|
**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — new-card entry falls back to `CardEntryUnavailable` only when neither mock mode nor Square credentials are configured). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash till sales record the gift-card value and are marked completed, with no tendered/change fields. Any change or overpayment is handled manually by the admin at the counter. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. The frontend computes deposit figures from the shared `POLICY` constants (`frontend/src/lib/constants/policy.ts`): `REQUIRED_DEPOSIT_PCT` (0.2) and `PROTECTED_DEPOSIT_MAX_PCT` (0.5), single-sourced with the backend's `refund_policy.go` (`RequiredDepositPct` / `ProtectedDepositMaxPct`) instead of per-file literals. A bounded PostgreSQL advisory try-lock (`pg_try_advisory_lock`, ~30 × 100ms ≈ 3s bound) serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour so a never-polled checkout cannot complete into an invisible, untracked charge.
|
||||||
|
|
||||||
**Gift Cards**: Multi-method purchase (cash, card machine, online card, giveaway). Inventory cards for stock management. 24-month rolling expiry. Idle account cleanup (2yr/5yr thresholds). Expired balance recovery with admin audit trail. Transaction audit log. Idempotency keys for purchases.
|
**Gift Cards**: Multi-method purchase (cash, card machine, online card, giveaway). Inventory cards for stock management. 24-month rolling expiry. Idle account cleanup (2yr/5yr thresholds). Expired balance recovery with admin audit trail. Transaction audit log. Idempotency keys for purchases.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL
|
|||||||
|
|
||||||
**Custom Services**: One-off or special-request services not in the permanent catalog. Admin management with create, edit, promote to permanent service (migrates booking references), and delete. Full CRUD API with search, popular sorting, and pagination. Can be added to any booking alongside regular services.
|
**Custom Services**: One-off or special-request services not in the permanent catalog. Admin management with create, edit, promote to permanent service (migrates booking references), and delete. Full CRUD API with search, popular sorting, and pagination. Can be added to any booking alongside regular services.
|
||||||
|
|
||||||
**Admin**: Today page with interactive calendar grid. Booking management (create, edit, reschedule, approve, cancel). User management with customer relationship data (spend, visits, top services). Custom services (one-off services with create/edit/promote/delete). Discount campaigns (time-based and milestone). Time blocker CRUD. Portfolio image upload with tag management. Gift card management. Business settings (VAT, gift card config). Notification queue with priority ordering.
|
**Admin**: Today page with interactive calendar grid. Booking management (create, edit, reschedule, approve, cancel). User management with customer relationship data (spend, visits, top services). Custom services (one-off services with create/edit/promote/delete). Discount campaigns (time-based and milestone). Time blocker CRUD. Portfolio image upload with tag management. Gift card management. Business settings (VAT, gift card config). Notification queue with priority ordering. The money-critical `critical_payment_log` / `refresh_token_reuse` notification queue is flood-capped at `adminnotify.MaxUnacknowledgedCriticalLogs` (100 unacknowledged rows per reason); at the cap, further inserts are suppressed with an operator-facing log until outstanding notifications are acknowledged (which re-arms inserts).
|
||||||
|
|
||||||
**Loyalty & Discounts**: 1 stamp per paid appointment (max 1/day). 10 stamps → 10% off via opt-in checkbox at payment or till. Stamps refunded on cancellation. Campaigns auto-apply at both payment and completion: time-based, per-user milestone, global milestone (in-person only), anniversary. All discounts stack additively against original total. Discount payment records excluded from refund calculations.
|
**Loyalty & Discounts**: 1 stamp per paid appointment (max 1/day). 10 stamps → 10% off via opt-in checkbox at payment or till. Stamps refunded on cancellation. Campaigns auto-apply at both payment and completion: time-based, per-user milestone, global milestone (in-person only), anniversary. All discounts stack additively against original total. Discount payment records excluded from refund calculations.
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ Default logins (password: `password`):
|
|||||||
```bash
|
```bash
|
||||||
cd backend && go build -o bin/backend ./main.go
|
cd backend && go build -o bin/backend ./main.go
|
||||||
cd frontend && npm ci && npm run build
|
cd frontend && npm ci && npm run build
|
||||||
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,440 backend test functions under the test,dev tags + 61 frontend vitest cases, as of 15 Aug 2026 (~2min)
|
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,498 backend test functions under the test,dev tags + 69 frontend vitest cases, as of 15 Aug 2026 (~2min)
|
||||||
cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min)
|
cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min)
|
||||||
# NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks —
|
# NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks —
|
||||||
# those suites share package-global state (Square mock ledger, in-memory webhook
|
# those suites share package-global state (Square mock ledger, in-memory webhook
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ JWT-based authentication with refresh token rotation, role-based access control,
|
|||||||
**Related:** [[Guest Accounts|8.8 Guest Accounts]], [[Admin Dashboard|5. Admin Dashboard]]
|
**Related:** [[Guest Accounts|8.8 Guest Accounts]], [[Admin Dashboard|5. Admin Dashboard]]
|
||||||
|
|
||||||
### 8.4 Rate Limiting
|
### 8.4 Rate Limiting
|
||||||
**What it does:** Progressive dual-window rate limiting on login/register endpoints. Burst: 30 requests per 5 seconds. Sustained: 120 requests per 60 seconds. Violators get progressive delays (500ms → 10s).
|
**What it does:** Progressive dual-window rate limiting on login/register endpoints. Burst: 30 requests per 5 seconds. Sustained: 120 requests per 60 seconds. Violators get progressive delays (500ms / 2s / 5s sleep tiers); only the top 10s abuse tier is rejected with a 429 (lower tiers sleep with backoff rather than hard-rejecting, so a shared NAT or proxy-collapsed IP is throttled instead of locked out for everyone).
|
||||||
|
|
||||||
**Layman summary:** "The system slows down rapid-fire requests to prevent abuse."
|
**Layman summary:** "The system slows down rapid-fire requests to prevent abuse."
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Slot reservations stored as `time_blocker` entries with `RESERVATION:*` descript
|
|||||||
|
|
||||||
Overlap checks now use `FOR UPDATE` row locks inside transactions — the overlap query runs inside `Begin`/`Commit` to prevent race conditions. Closing-hours validation extracted into reusable helpers: `checkClosingHours()` validates end-time against closing, `getClosingTimeForDate()` resolves closing time from either current `working_hours` or a pending staged default hours change. A shared `repo.go` provides common DB query helpers across booking handlers.
|
Overlap checks now use `FOR UPDATE` row locks inside transactions — the overlap query runs inside `Begin`/`Commit` to prevent race conditions. Closing-hours validation extracted into reusable helpers: `checkClosingHours()` validates end-time against closing, `getClosingTimeForDate()` resolves closing time from either current `working_hours` or a pending staged default hours change. A shared `repo.go` provides common DB query helpers across booking handlers.
|
||||||
|
|
||||||
**Deposit system:** Bookings have a 24h deposit deadline. Unpaid bookings enter `pending_release` — the slot becomes vulnerable to eviction by overlapping new bookings. Payment of >= 20% of the total at any point promotes back to `confirmed`. Evicted bookings enter `deposit_lapsed`. Admin forgiveness (`forgive_fees`/`forgive_noshow`) on cancel and reschedule. Refund calculation uses notice-period tiers (72h/24h full/partial/none) with deposit protection capping retention at 50% of total.
|
**Deposit system:** Bookings have a 24h deposit deadline. Unpaid bookings enter `pending_release` — the slot becomes vulnerable to eviction by overlapping new bookings. Payment of >= 20% of the total at any point promotes back to `confirmed`. Evicted bookings enter `deposit_lapsed`. Admin forgiveness (`forgive_fees`/`forgive_noshow`) on cancel and reschedule. Refund calculation uses notice-period tiers (72h/24h full/partial/none) with deposit protection capping retention at 50% of total. The frontend derives the 20%/50% figures from the shared `POLICY` constants (`frontend/src/lib/constants/policy.ts`: `REQUIRED_DEPOSIT_PCT` = 0.2, `PROTECTED_DEPOSIT_MAX_PCT` = 0.5), single-sourced with the backend's `refund_policy.go`, instead of per-file literals.
|
||||||
|
|
||||||
Service eligibility filters by age (min age on service) and patch test validity (6-month expiry, 24-hour notice period). Guest accounts are disposable — no identity tracking across bookings, PII scrubbed 6 months after appointment.
|
Service eligibility filters by age (min age on service) and patch test validity (6-month expiry, 24-hour notice period). Guest accounts are disposable — no identity tracking across bookings, PII scrubbed 6 months after appointment.
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ npm run dev # Dev server with HMR
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
go test -tags "test,dev" ./... # 2,440 backend test functions passed (4 skipped) + 61 frontend vitest cases, as of 15 Aug 2026
|
go test -tags "test,dev" ./... # 2,498 backend test functions passed (4 skipped) + 69 frontend vitest cases, as of 15 Aug 2026
|
||||||
go test -tags "test,dev" -v -run TestName ./... # Single test
|
go test -tags "test,dev" -v -run TestName ./... # Single test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ Backend (:8080)
|
|||||||
| `RequireVerified` | Allows verified_email or admin |
|
| `RequireVerified` | Allows verified_email or admin |
|
||||||
| `RequireRole(roles...)` | Generic role check |
|
| `RequireRole(roles...)` | Generic role check |
|
||||||
| `RateLimit(limit, window)` | IP-based rate limiting (supports CF-Connecting-IP header). Dev build tag (`dev`): no-op pass-through. |
|
| `RateLimit(limit, window)` | IP-based rate limiting (supports CF-Connecting-IP header). Dev build tag (`dev`): no-op pass-through. |
|
||||||
| `ProgressiveRateLimit` | Per-IP dual-window rate limiter for bot-spam prevention. Burst: 30 req/5s, Sustained: 120 req/60s. Progressive delays (500ms–10s). Applied to login/register. Skips in dev build. |
|
| `ProgressiveRateLimit` | Per-IP dual-window rate limiter for bot-spam prevention. Burst: 30 req/5s, Sustained: 120 req/60s. Progressive delays: the 500ms / 2s / 5s tiers **sleep** (backoff); only the top 10s abuse tier rejects the request 429 immediately (`progressiveRejectDelayMs`). Lower tiers are deliberately never hard-rejected, so under a shared NAT (or any `TRUST_PROXY_HEADERS=false` deployment where every client collapses onto the proxy's IP) one abusive client throttles with backoff instead of locking out the whole surface. Applied to login/register. Skips in dev build. |
|
||||||
| `JsonContentType` | Sets `Content-Type: application/json` on all API responses. Individual handlers that need to override (e.g. binary/image responses) set their own Content-Type header after this middleware runs. Applied globally in `main.go:182`. This replaced ~80+ individual `w.Header().Set("Content-Type", "application/json")` calls across all handlers. |
|
| `JsonContentType` | Sets `Content-Type: application/json` on all API responses. Individual handlers that need to override (e.g. binary/image responses) set their own Content-Type header after this middleware runs. Applied globally in `main.go:182`. This replaced ~80+ individual `w.Header().Set("Content-Type", "application/json")` calls across all handlers. |
|
||||||
| `RespondJSON(w, status, data)` | Helper function (in `response.go`, not middleware) for consistent JSON responses. Always sets `Content-Type: application/json`. |
|
| `RespondJSON(w, status, data)` | Helper function (in `response.go`, not middleware) for consistent JSON responses. Always sets `Content-Type: application/json`. |
|
||||||
| `RespondError(w, status, message)` | Helper that wraps `RespondJSON` with `{"error": message}`. **De-scoped for the payments package (Aug 2026 remediation):** the payments handlers deliberately keep using raw `http.Error` — the argument order differs (`http.Error(w, msg, status)` vs `RespondError(w, status, msg)`), the frontend only checks `res.ok` on those endpoints, and migrating ~396 call sites mid money-critical remediation added regression risk for zero functional gain. Recorded as a permanent MINOR/consistency backlog item; revisit only if the frontend starts reading structured error bodies from payments endpoints. Elsewhere, `RespondError` is the standard for consistent JSON error format. |
|
| `RespondError(w, status, message)` | Helper that wraps `RespondJSON` with `{"error": message}`. **De-scoped for the payments package (Aug 2026 remediation):** the payments handlers deliberately keep using raw `http.Error` — the argument order differs (`http.Error(w, msg, status)` vs `RespondError(w, status, msg)`), the frontend only checks `res.ok` on those endpoints, and migrating ~396 call sites mid money-critical remediation added regression risk for zero functional gain. Recorded as a permanent MINOR/consistency backlog item; revisit only if the frontend starts reading structured error bodies from payments endpoints. Elsewhere, `RespondError` is the standard for consistent JSON error format. |
|
||||||
|
|
||||||
|
**bcrypt concurrency budget (auth):** `/login` and `/register` share one global 20-slot counting semaphore (`maxConcurrentBcrypt` / `authBcryptSlots`, `handlers/auth/local.go`). The progressive per-IP limiter sleeps before the handler, so without the cap a flood of throttled requests could stack an unbounded number of goroutines that all hit bcrypt the moment their sleeps elapse, a CPU-amplification vector (a register-botnet also burns CPU on `bcrypt.GenerateFromPassword`). Beyond the cap the request is rejected 429 "server busy" immediately, before anything is processed. The budget is deliberately global (login *and* register draw from the same pool): 20 genuine concurrent bcrypt operations (~1.2s of wall time) is far beyond a single salon's load, and bounding the CPU is the point, an accepted bounded-DoS trade-off.
|
||||||
|
|
||||||
### Database Layer (`db/`)
|
### Database Layer (`db/`)
|
||||||
|
|
||||||
- **Driver**: pgx/v5 (PostgreSQL)
|
- **Driver**: pgx/v5 (PostgreSQL)
|
||||||
@@ -780,6 +782,8 @@ validTransitions := map[string]map[string]bool{
|
|||||||
- In the 24-72h window: you always get back everything above the protected deposit.
|
- In the 24-72h window: you always get back everything above the protected deposit.
|
||||||
- Under 24h (no-show): the protected deposit is the maximum that can be retained. Any amount paid beyond the protected deposit is refunded.
|
- Under 24h (no-show): the protected deposit is the maximum that can be retained. Any amount paid beyond the protected deposit is refunded.
|
||||||
|
|
||||||
|
**Refund status semantics (APPROVED):** Square's refund statuses map to local rows through the single shared `SquareRefundStatusToLocal` (`errors.go`): COMPLETED → `completed` (terminal), FAILED/REJECTED → `failed` (terminal), PENDING/CANCELED/unknown → non-terminal (the row stays `pending` for the sweep). APPROVED is the deliberate exception: the shared mapping nominally resolves it to `completed`, the synchronous refund handlers' explicit override, because a blocking APPROVED result is final on that path. But the webhook (event-driven) treats it as NON-terminal and leaves the row `pending` with the `square_refund_id` recorded, so a later FAILED/REJECTED/CANCELED event can still demote it. COMPLETED is the only status that flips a local row to `completed`. The over-refund guard counts `completed` and in-flight `pending` refunds as money already returned (a completed refund must never be demoted out of the guard once money moved); `failed` refunds are excluded so a declined refund never blocks a retry.
|
||||||
|
|
||||||
**Policy constants** (shared between frontend `policy.ts` and backend `refund_policy.go`):
|
**Policy constants** (shared between frontend `policy.ts` and backend `refund_policy.go`):
|
||||||
|
|
||||||
| Constant | Value | Meaning |
|
| Constant | Value | Meaning |
|
||||||
@@ -834,6 +838,8 @@ validTransitions := map[string]map[string]bool{
|
|||||||
- A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
|
- A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
|
||||||
- **Posture switch (`TWO_FACTOR_FALLBACK`, default `true`):** read by `twoFactorFallbackEnabled` (`handlers/payments/twofa.go`) and logged at startup by `main.go`. `false` = SCA-only posture: a saved-card charge carrying no Square `verification_token` is denied 402 `verification_required` (the frontend shows the SCA challenge; if the bank cannot complete it, the charge fails) rather than falling back to the 2FA gate. `true` (the default) = the 2FA gate may authorise a token-less saved-card charge when SCA is unavailable, the user has enabled 2FA, and a delivery channel exists.
|
- **Posture switch (`TWO_FACTOR_FALLBACK`, default `true`):** read by `twoFactorFallbackEnabled` (`handlers/payments/twofa.go`) and logged at startup by `main.go`. `false` = SCA-only posture: a saved-card charge carrying no Square `verification_token` is denied 402 `verification_required` (the frontend shows the SCA challenge; if the bank cannot complete it, the charge fails) rather than falling back to the 2FA gate. `true` (the default) = the 2FA gate may authorise a token-less saved-card charge when SCA is unavailable, the user has enabled 2FA, and a delivery channel exists.
|
||||||
- **Residual brute-force exposure (accepted, bounded by B11):** the 5-attempt counter is per-user and in-memory (`internal/twofa`, `MaxAttempts=5`, `AttemptWindow` = 10 minutes), and it resets **only** on a successful verify or when the attempt window lapses — **never** on a fresh-code delivery (`internal/twofa/twofa.go:49-51`; `ResetAttempts` is called only on successful verify, B11b). A fresh code therefore never grants a fresh guessing budget, and minting is additionally throttled to one code per minute per user (`twoFAMintCooldown`). The exposure that remains is a locked-out legitimate user who lost their code: they must wait out the 10-minute window, because a hard per-user lockout would strand them with no email/SMS transport to recover (P6). Revisit when real delivery lands. (Because 2FA is now backup-only, the exposure is confined to the no-SCA fallback path — it no longer fronts every saved-card charge.)
|
- **Residual brute-force exposure (accepted, bounded by B11):** the 5-attempt counter is per-user and in-memory (`internal/twofa`, `MaxAttempts=5`, `AttemptWindow` = 10 minutes), and it resets **only** on a successful verify or when the attempt window lapses — **never** on a fresh-code delivery (`internal/twofa/twofa.go:49-51`; `ResetAttempts` is called only on successful verify, B11b). A fresh code therefore never grants a fresh guessing budget, and minting is additionally throttled to one code per minute per user (`twoFAMintCooldown`). The exposure that remains is a locked-out legitimate user who lost their code: they must wait out the 10-minute window, because a hard per-user lockout would strand them with no email/SMS transport to recover (P6). Revisit when real delivery lands. (Because 2FA is now backup-only, the exposure is confined to the no-SCA fallback path — it no longer fronts every saved-card charge.)
|
||||||
|
- **Mint cooldown survives the gate verify (Round 2 Loop B finding 6):** a successful gate `Check` does **not** clear the per-user mint-cooldown stamp (`LastMintAt`); the charge may still fail and `reissueTwoFACodeAfterFailedCharge` throttles against it. The stamp is cleared only at terminal success (`twofa.ConsumePendingCode`). A fresh-charge failure re-issues a code; when the re-issue is refused or skipped by the cooldown, a **per-issue-capped** `critical_payment_log` admin alert (`alertReissueFail`, one unacknowledged row per stranded user, atomic `INSERT ... WHERE NOT EXISTS`) is raised so the operator knows to mint a code manually.
|
||||||
|
- **StateFor saturation is immutable and fail-closed (Round 2 Loop B finding 3):** when the in-memory attempt map is at capacity (`MaxTrackedAttempts`), `StateFor` returns the shared `saturatedLockedState` for every untracked user, a permanently-locked state (brute force impossible), never a fresh per-request budget. It is immutable: `LastMintAt` writes via `SetLastMintAtLocked` are no-ops (one user's mint must not throttle everyone) and `ClearMintCooldownForUser` is a no-op on it. Eviction never drops an in-window record with a non-zero attempt counter, which would reset a genuine user's counter and grant a fresh guessing budget. The map drains as locked-out windows lapse.
|
||||||
|
|
||||||
**State:** stored on `users` — `two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash`, `two_factor_pending_code_expires` (10-minute TTL). Only a digest of the code is stored in the DB — never the plaintext. The digest is **HMAC-SHA256 keyed by `TWO_FACTOR_PEPPER`** when that env var is set (`hashTwoFACode`, `handlers/user/twofa.go`); an unset pepper falls back to the legacy unsalted SHA-256 digest **only** in dev/test builds and for the legacy-row migration window — production builds can never persist an unsalted digest because code issuance **fails closed** without the pepper (see `handlers/user/twofa_prod.go`). **Code delivery is build-dependent and production fails closed:** the **intended** channel is email/SMS (the method chosen at setup) — **not yet wired (P6)**. Until that transport lands, the **only** production channel is the operator's explicit opt-in to the insecure stdout-log relay: with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` the plaintext code is written to the server log with a `[2FA]` prefix (user id and code on **separate** lines, so a single record cannot trivially pair them), and the operator relays it. Without the opt-in, production code issuance is refused (503 / `errTwoFADeliveryUnavailable`) so no user can complete setup or disable 2FA, and every enforced fallback saved-card payment 403s with no way forward — a loud failure rather than a silent lockout. Dev/test builds always write the `[2FA]` log line (and, when enforcement is off, the setup endpoint also returns the code and verify accepts any code, so the flow is testable without grepping logs). Each fresh code is checked under a shared **5-attempt lockout** (`twoFAMaxAttempts = 5` consecutive failed verifies invalidate the pending code); the counter resets only on a successful verify or when the 10-minute attempt window elapses — never on a fresh-code delivery (B11b, see the residual brute-force note above).
|
**State:** stored on `users` — `two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash`, `two_factor_pending_code_expires` (10-minute TTL). Only a digest of the code is stored in the DB — never the plaintext. The digest is **HMAC-SHA256 keyed by `TWO_FACTOR_PEPPER`** when that env var is set (`hashTwoFACode`, `handlers/user/twofa.go`); an unset pepper falls back to the legacy unsalted SHA-256 digest **only** in dev/test builds and for the legacy-row migration window — production builds can never persist an unsalted digest because code issuance **fails closed** without the pepper (see `handlers/user/twofa_prod.go`). **Code delivery is build-dependent and production fails closed:** the **intended** channel is email/SMS (the method chosen at setup) — **not yet wired (P6)**. Until that transport lands, the **only** production channel is the operator's explicit opt-in to the insecure stdout-log relay: with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` the plaintext code is written to the server log with a `[2FA]` prefix (user id and code on **separate** lines, so a single record cannot trivially pair them), and the operator relays it. Without the opt-in, production code issuance is refused (503 / `errTwoFADeliveryUnavailable`) so no user can complete setup or disable 2FA, and every enforced fallback saved-card payment 403s with no way forward — a loud failure rather than a silent lockout. Dev/test builds always write the `[2FA]` log line (and, when enforcement is off, the setup endpoint also returns the code and verify accepts any code, so the flow is testable without grepping logs). Each fresh code is checked under a shared **5-attempt lockout** (`twoFAMaxAttempts = 5` consecutive failed verifies invalidate the pending code); the counter resets only on a successful verify or when the 10-minute attempt window elapses — never on a fresh-code delivery (B11b, see the residual brute-force note above).
|
||||||
|
|
||||||
@@ -912,6 +918,8 @@ validTransitions := map[string]map[string]bool{
|
|||||||
- `GET /api/admin/notifications/unread-count` — Returns `{"count": N}` for the bell icon.
|
- `GET /api/admin/notifications/unread-count` — Returns `{"count": N}` for the bell icon.
|
||||||
- `POST /api/admin/notifications/{id}/acknowledge` — Sets `acknowledged_at = NOW()`. Idempotent (404 if already acknowledged).
|
- `POST /api/admin/notifications/{id}/acknowledge` — Sets `acknowledged_at = NOW()`. Idempotent (404 if already acknowledged).
|
||||||
|
|
||||||
|
**Critical-log flood cap (Round 2 Loop B):** the money-critical admin-notification reasons, `critical_payment_log` and the `refresh_token_reuse` theft alert, share a global flood cap: at most `adminnotify.MaxUnacknowledgedCriticalLogs` = **100** unacknowledged rows per reason. The cap lives centrally in `internal/adminnotify` and is folded **atomically** into the INSERT at every insert site (the webhook's dispute/booking/unknown-event/orphan-replay `critical_payment_log` inserts, the JWT `refresh_token_reuse` insert, and the account-erasure Square-erasure insert), closing the count-then-insert race so concurrent events cannot overshoot together. Once the unacknowledged queue for a reason reaches the cap, further inserts are suppressed and a log line tells the operator what happened and how to re-arm: **acknowledge outstanding notifications**. Acknowledging rows via `POST /api/admin/notifications/{id}/acknowledge` drops the count below the cap and inserts resume. The 2FA reissue-fail alert is the deliberate exception: it is capped **per issue** (one unacknowledged row per stranded user) rather than globally, so one customer's alert is never suppressed by other users' rows filling the shared bucket.
|
||||||
|
|
||||||
**Priority order** (SQL CASE WHEN):
|
**Priority order** (SQL CASE WHEN):
|
||||||
|
|
||||||
| Priority | Reason | Frontend Action |
|
| Priority | Reason | Frontend Action |
|
||||||
@@ -1332,7 +1340,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
|
|||||||
|
|
||||||
### Test Coverage
|
### Test Coverage
|
||||||
|
|
||||||
**2,440 backend test functions compiled** (4 skipped, 0 failures) plus **61 frontend vitest cases** — as of 15 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
|
**2,498 backend test functions compiled** (4 skipped, 0 failures) plus **69 frontend vitest cases** — as of 15 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
|
||||||
|
|
||||||
| Package | Coverage Area |
|
| Package | Coverage Area |
|
||||||
|---------|--------------|
|
|---------|--------------|
|
||||||
|
|||||||
@@ -475,9 +475,11 @@ Every refund passes through a layered guard stack:
|
|||||||
|
|
||||||
1. **Residual per payment.** Before anything is refunded, the system counts what each payment has *already* refunded — including refunds still **pending at Square**, since an in-flight refund might land at any moment — and refunds only up to the gap between paid and already-out-the-door.
|
1. **Residual per payment.** Before anything is refunded, the system counts what each payment has *already* refunded — including refunds still **pending at Square**, since an in-flight refund might land at any moment — and refunds only up to the gap between paid and already-out-the-door.
|
||||||
2. **Booking-level cap.** The total refunded can never exceed what the booking is refundable for (completed real payments minus completed *and* pending refunds).
|
2. **Booking-level cap.** The total refunded can never exceed what the booking is refundable for (completed real payments minus completed *and* pending refunds).
|
||||||
3. **Status discipline.** Every refund row is `pending` (in flight, counted by the guards), `completed` (Square confirmed), or `failed` (definitively rejected and *excluded* from the guards, so a declined refund never blocks a retry). A second attempt on a payment with a pending refund is refused with "already being processed".
|
3. **Status discipline.** Every refund row is `pending` (in flight, counted by the guards), `completed` (Square confirmed, counted by the guards), or `failed` (definitively rejected and *excluded* from the guards, so a declined refund never blocks a retry). A second attempt on a payment with a pending refund is refused with "already being processed".
|
||||||
4. **The never-assume rule.** If Square's response is ambiguous, the refund stays `pending` — marking it `failed` would let the guards treat the money as returned when it might not have been. The sweeps ([[#Chapter 7: The Reconciliation Engine, Three Background Sweeps]]) resolve it later.
|
4. **The never-assume rule.** If Square's response is ambiguous, the refund stays `pending` — marking it `failed` would let the guards treat the money as returned when it might not have been. The sweeps ([[#Chapter 7: The Reconciliation Engine, Three Background Sweeps]]) resolve it later.
|
||||||
|
|
||||||
|
One status needs its own explanation. Square reports a refund as `APPROVED` once the money is authorised but the row can still settle afterwards, and on the event-driven webhook path that is deliberately **non-terminal**: the row is left `pending` with the Square refund ID recorded, so a later FAILED, REJECTED, or CANCELED event can still demote it. Promoting on APPROVED would strand the row at `completed` (the FAILED demotion only touches `pending` rows) and the over-refund guard counts a completed refund as money already returned: money that never moved would block the amount forever. `COMPLETED` is the only status that flips a local row to `completed`. The one explicit override is the synchronous handlers' blocking APPROVED result: the shared Square-to-local status mapping resolves a refund Square fully approved in the blocking request to `completed`, because on that path the approval is final. Failed refunds stay excluded from the guards either way, so a declined refund never blocks a retry.
|
||||||
|
|
||||||
### What happens when a refund gets stuck
|
### What happens when a refund gets stuck
|
||||||
|
|
||||||
Refunds follow the same pending-first pattern as payments ([[#Chapter 4: Paying for a Booking (User & Admin Journeys)]]): the row is written `pending` and committed before Square is called, so a lost response leaves the row as the truth and a retry reuses the *same stored idempotency key* rather than issuing a second refund. A background job sweeps pending refunds every five minutes, with two deliberate bounds:
|
Refunds follow the same pending-first pattern as payments ([[#Chapter 4: Paying for a Booking (User & Admin Journeys)]]): the row is written `pending` and committed before Square is called, so a lost response leaves the row as the truth and a retry reuses the *same stored idempotency key* rather than issuing a second refund. A background job sweeps pending refunds every five minutes, with two deliberate bounds:
|
||||||
@@ -536,7 +538,15 @@ The answer comes from comparing the replayed payment's creation time against rep
|
|||||||
|
|
||||||
### The B1 auto-refund and the re-poll pass
|
### The B1 auto-refund and the re-poll pass
|
||||||
|
|
||||||
Once the discriminator says "this is a new duplicate the sweep just minted", the customer must not stay charged twice. The sweep refunds the duplicate at Square under a deterministic key derived from the duplicate's payment ID, so a re-run can never double-refund. If the refund completes, the parent row is failed and any till-sale funding is clawed back. If Square leaves the refund pending, which is non-terminal, the row stays pending and a dedicated re-poll pass settles it only when Square reports the refund COMPLETED. A refund stuck pending for over 48 hours is escalated to a critical admin notification.
|
Once the discriminator says "this is a new duplicate the sweep just minted", the customer must not stay charged twice. The sweep refunds the duplicate at Square under a deterministic key derived from the duplicate's payment ID, so a re-run can never double-refund. Every attempt is counted against the row's `b1_attempts` cap of three, so a refund that keeps failing can never drive an unbounded replay loop. The refund resolves three ways:
|
||||||
|
|
||||||
|
- **Square COMPLETED.** The parent row is failed and any till-sale funding is clawed back.
|
||||||
|
- **Square PENDING.** Non-terminal, so the row stays pending and a dedicated re-poll pass settles it only when Square reports the refund COMPLETED. A refund stuck pending for over 48 hours is escalated to a critical admin notification.
|
||||||
|
- **Square REJECTED, or the refund call errors.** The parent row is failed immediately with a CRITICAL notification and `b1_attempts` is pinned to the cap, so the expired key is never replayed. The old behaviour (leave the row pending and retry next run) let a transport-error refund re-mint a fresh charge each sweep, stacking up to three unauthorized charges before the cap bound it.
|
||||||
|
|
||||||
|
The webhook closes the same race from the live side. When a `refund.completed` event promotes a sweep auto-refund, the handler also resolves the parent row exactly as the re-poll pass would: a payments-table refund marks the still-pending parent payment failed, and a till-sale refund claws back the funded gift card. The sweep's in-flight guard counts both `pending` and `completed` sweep-dup refunds, so a webhook-promoted refund can never fall off the guard and let the next sweep re-replay the expired key into stacked unauthorized charges.
|
||||||
|
|
||||||
|
One caveat on a capped-fail. When a till sale's duplicate charge stands at Square, its funded gift card is *not* auto-clawed back: the money at Square is real, and reversing funding the merchant still holds would lose it. The sweep instead writes a `gift_card_transactions` trace (`awaiting_reversal`) so the operator can see the outstanding funding and follow the manual path: refund the duplicate charge at Square first, then run the till-sale funding clawback once the duplicate is gone.
|
||||||
|
|
||||||
One more guard runs first: before rescuing a stale payment, the sweep re-reads the booking's status under a row lock. A charge that lands after the booking was cancelled must never be silently completed, because the cancellation refund path only sees completed payments and would miss it. On a cancelled booking the row is failed and the admin notified, so the customer is refunded manually.
|
One more guard runs first: before rescuing a stale payment, the sweep re-reads the booking's status under a row lock. A charge that lands after the booking was cancelled must never be silently completed, because the cancellation refund path only sees completed payments and would miss it. On a cancelled booking the row is failed and the admin notified, so the customer is refunded manually.
|
||||||
|
|
||||||
@@ -560,6 +570,9 @@ flowchart TD
|
|||||||
L --> M{Refund at Square}
|
L --> M{Refund at Square}
|
||||||
M -->|COMPLETED| N[Fail parent + clawback]
|
M -->|COMPLETED| N[Fail parent + clawback]
|
||||||
M -->|PENDING| O[Re-poll pass; 48h stale - critical notification]
|
M -->|PENDING| O[Re-poll pass; 48h stale - critical notification]
|
||||||
|
M -->|REJECTED or transport error| AA[Fail parent immediately + CRITICAL<br/>b1_attempts pinned - expired key never replayed]
|
||||||
|
AA --> AB[Till sale with funded gift card: write a<br/>gift_card_transactions awaiting_reversal trace<br/>refund the duplicate at Square first, then claw back]
|
||||||
|
N --> AC[Webhook refund.completed also resolves the parent -<br/>mirrors the re-poll pass, till-sale clawback included]
|
||||||
D -->|No - past 24h| P[Pass 2: reconcile by payment id<br/>or blind-fail with warning]
|
D -->|No - past 24h| P[Pass 2: reconcile by payment id<br/>or blind-fail with warning]
|
||||||
K --> Q[Gate: re-read booking under row lock]
|
K --> Q[Gate: re-read booking under row lock]
|
||||||
Q --> R{Booking cancelled?}
|
Q --> R{Booking cancelled?}
|
||||||
@@ -650,7 +663,7 @@ A money-family event the app does not yet handle, anything under payment, refund
|
|||||||
|
|
||||||
### Orphaned replay charges
|
### Orphaned replay charges
|
||||||
|
|
||||||
The webhook handler is also the second half of the B1 duplicate-charge story from [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps]]. When the sweep replays an expired idempotency key and Square lands a new charge, that charge has no local payment row. Its completion event therefore arrives as an orphan: a COMPLETED payment matching nothing. The handler hunts for the pending origin row by the replayed idempotency key, or by the reference and amount the sweep preserved, marks that origin row failed so the sweep will not blind-fail or double-rescue it later, and raises a notification. The auto-refund itself stays with the sweep; the webhook only detects, notifies, and settles the origin.
|
The webhook handler is also the second half of the B1 duplicate-charge story from [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps]]. When the sweep replays an expired idempotency key and Square lands a new charge, that charge has no local payment row. Its completion event therefore arrives as an orphan: a COMPLETED payment matching nothing. The handler hunts for the pending origin row by the replayed idempotency key, or by the reference and amount the sweep preserved, marks that origin row failed so the sweep will not blind-fail or double-rescue it later, and raises a notification. The auto-refund itself stays with the sweep; the webhook only detects, notifies, and settles the origin. And when the webhook later sees a `refund.completed` event for that auto-refund, it resolves the still-pending parent row in the same way the sweep's re-poll pass would: a payments-table refund marks the parent failed, a till-sale refund claws back the funding, so a webhook-promoted refund never falls off the sweep's in-flight guard (see [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps]]).
|
||||||
|
|
||||||
### Keeping the books tidy
|
### Keeping the books tidy
|
||||||
|
|
||||||
@@ -976,6 +989,10 @@ Once the customer verifies the code, 2FA is enabled. Disabling is symmetric and
|
|||||||
|
|
||||||
The brute-force defences around codes are layered. A user gets five failed attempts before the pending code is destroyed and further attempts are refused until the attempt window lapses. Minting a fresh code never resets the failed-attempt counter, and fresh mints are throttled to one per minute per user. Those two rules together close the classic loop where an attacker mints a code, burns five guesses, mints again, forever; a locked-out user must simply wait out the window.
|
The brute-force defences around codes are layered. A user gets five failed attempts before the pending code is destroyed and further attempts are refused until the attempt window lapses. Minting a fresh code never resets the failed-attempt counter, and fresh mints are throttled to one per minute per user. Those two rules together close the classic loop where an attacker mints a code, burns five guesses, mints again, forever; a locked-out user must simply wait out the window.
|
||||||
|
|
||||||
|
The one-per-minute mint cooldown now survives the gate verify. A successful code check does *not* clear the cooldown stamp, because the charge may still fail and the re-issue path needs the stamp to throttle; only a terminal success clears it. When a fresh charge fails after consuming the customer's code, the system re-issues a fresh code, and if that re-issue is refused or skipped by the cooldown it raises a critical-payment admin alert capped per issue (one unacknowledged row per stranded customer) so the operator knows to mint a code manually. A stranded customer is an operator-facing incident, never a silent log line.
|
||||||
|
|
||||||
|
The in-memory attempt map is bounded and fail-closed. When it reaches capacity (every tracked user sits inside a lockout window), the shared state returned for untracked users is a *permanently-locked* one instead of a fresh per-user budget: brute force becomes impossible for everyone until a real window lapses, and it never silently re-arms a guessing budget. Under that saturated state the mint-cooldown stamp writes are no-ops, so one user's mint can never throttle every other user, and eviction never drops an in-window record with a non-zero attempt counter, because dropping it would reset a genuine user's counter and grant a fresh guessing budget.
|
||||||
|
|
||||||
### The charge-time gate
|
### The charge-time gate
|
||||||
|
|
||||||
The gate's key decision is **B10**: merely having 2FA enabled does not unlock saved-card charges. In an enforced environment, every saved-card charge must present an *actual code at charge time*. The enabled flag proves the user went through setup; the code proves the owner is present *right now*. This closes the hole where an attacker with a captured session could charge a saved card just because 2FA was configured.
|
The gate's key decision is **B10**: merely having 2FA enabled does not unlock saved-card charges. In an enforced environment, every saved-card charge must present an *actual code at charge time*. The enabled flag proves the user went through setup; the code proves the owner is present *right now*. This closes the hole where an attacker with a captured session could charge a saved card just because 2FA was configured.
|
||||||
@@ -1136,7 +1153,7 @@ The Today page is the operational dashboard: the current and next appointments,
|
|||||||
|
|
||||||
### The admin audit log
|
### The admin audit log
|
||||||
|
|
||||||
Every money-touching admin action writes a row to `admin_audit_log`: who did it, what kind of action it was, which customer it touched, and a details object. Today the audited actions are the five that matter most: **2fa_code_mint** (the admin relayed a verification code to a customer, with whether the code was fresh or reused), **2fa_fallback_charge** (a saved-card charge authorised by the 2FA backup because SCA was unavailable — the row records `sca_performed:false`, the card's last four digits, and the charge reference), **saved_card_charge** (the admin charged a customer's saved card), **till_saved_card_charge** (the same, at the till), and **balance_check** (the admin inspected a customer's gift-card balance).
|
Every money-touching admin action writes a row to `admin_audit_log`: who did it, what kind of action it was, which customer it touched, and a details object. The audited actions cover the whole money surface, not just saved-card charges and the 2FA mint: **2fa_code_mint** (the admin relayed a verification code to a customer, with whether the code was fresh or reused), **2fa_fallback_charge** (a saved-card charge authorised by the 2FA backup because SCA was unavailable, the row records `sca_performed:false`, the card's last four digits, and the charge reference), **saved_card_charge** (the admin charged a customer's saved card) and **till_saved_card_charge** (the same, at the till), **balance_check** (the admin inspected a customer's gift-card balance), **admin_cash_charge** and **admin_giftcard_payment** (cash and gift-card terminal charges on a booking, written *after* the money commits so a failed charge never leaves a false audit row), **admin_booking_refund** (the cancellation-refund path), **admin_reschedule_fee_forgiven** (fee forgiveness on a reschedule), **gift_card_transfer** (value moved between two unredeemed cards), **giftcard_clawback** (a funding reversal by the till handler, the sweep, or the webhook), and the admin gift-card lifecycle actions **admin_gift_card_create** and **admin_gift_card_topup**. Guest bookings audit too, with a NULL target user, exactly like the till flow.
|
||||||
|
|
||||||
The writes are best-effort and non-fatal. Each audit insert runs in its own transaction, so a failed audit write rolls back only the audit write and can never abort a completed charge or a money movement. The audit log is deliberately the one part of the money path that is allowed to fail silently, because protecting the charge matters more than protecting the record of the charge. If the audit write fails, the money is still safe and the operator is still accountable through the payment row itself.
|
The writes are best-effort and non-fatal. Each audit insert runs in its own transaction, so a failed audit write rolls back only the audit write and can never abort a completed charge or a money movement. The audit log is deliberately the one part of the money path that is allowed to fail silently, because protecting the charge matters more than protecting the record of the charge. If the audit write fails, the money is still safe and the operator is still accountable through the payment row itself.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user