Files
popertots 36887167c6 fix: loop-A fresh review (503c326 baseline) — overflow-guard bypass, discounted-deposit retry, GDPR audit scrub, till cap, sweep rescue, 2FA reissue + SCA retry, consolidation round
Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:

MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance

SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue

DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)

Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
2026-08-22 00:34:50 +01:00

142 lines
5.8 KiB
Go

package payments
import (
"context"
"log"
"crussell/db"
)
type VATConfig struct {
IsVATRegistered bool
DefaultVATRate float64
VoucherType string
}
// GetVATConfig reads VAT config using the given querier, which can be either
// a *db.PoolProxy (for non-transactional reads), a pgx.Tx (to read inside a
// transaction), or any other type that implements db.Querier.
func GetVATConfig(ctx context.Context, q db.Querier) (*VATConfig, error) {
var cfg VATConfig
err := q.QueryRow(ctx, `
SELECT COALESCE(is_vat_registered, FALSE),
COALESCE(default_vat_rate, 20.0),
COALESCE(voucher_type, 'SPV')
FROM business_settings
LIMIT 1
`).Scan(&cfg.IsVATRegistered, &cfg.DefaultVATRate, &cfg.VoucherType)
if err != nil {
return nil, err
}
// HMRC VAT Notice 700/7: a salon-only gift card is a single-purpose
// voucher (SPV) by definition — MPV is legally unavailable for this
// business. A stored "MPV" would suppress VAT on gift-card purchases AND
// on gift-card-funded service payments (an output tax leak), so it is
// overridden to SPV on this READ path so VAT applies to gift-card
// purchases regardless of the stored setting.
if cfg.VoucherType == "MPV" {
log.Printf("VAT config override: voucher_type is 'MPV', which is unavailable for this salon-only gift-card business — treating it as 'SPV' for VAT application (HMRC VAT Notice 700/7)")
cfg.VoucherType = "SPV"
}
return &cfg, nil
}
// vatAppliesToVoucher reports whether VAT should be applied to a gift-card
// (voucher) sale or purchase. A gift card is a single-purpose voucher (SPV)
// and VAT applies to SPV sales whenever the business is VAT registered; it is
// not applied when the business is not registered or the config is absent.
// GetVATConfig already normalises "MPV" to "SPV", so this returns true unless
// the business is explicitly not VAT registered.
func vatAppliesToVoucher(cfg *VATConfig) bool {
if cfg == nil {
return false
}
return cfg.IsVATRegistered && cfg.VoucherType == "SPV"
}
// effectiveVoucherTypeForPurchase returns the voucher type that MUST be
// recorded on a gift card at purchase time. HMRC VAT Notice 700/7: a salon-only
// gift card is a single-purpose voucher (SPV) by definition — MPV is legally
// unavailable for this business, so a stored 'MPV' is overridden to 'SPV'
// everywhere (GetVATConfig does the same on the read path). Writing the
// EFFECTIVE type into voucher_type_at_purchase matters: the redemption path
// (handlers.go) defers VAT to redemption only for cards whose stored
// voucher_type_at_purchase is 'MPV' — recording raw 'MPV' while VAT was
// already collected at sale (via the GetVATConfig SPV override) would apply
// VAT a SECOND time at redemption.
func effectiveVoucherTypeForPurchase(raw string) string {
if raw == "MPV" {
return "SPV"
}
return raw
}
// ApplyVATToBookingPayment reads VAT config and calls apply_vat_to_payment
// on a booking payment record. The q parameter is used for both reading the
// VAT config and for the defensive payment-method check, ensuring all reads
// are consistent with the caller's transactional context. Errors are logged
// — VAT failure should not block the payment flow.
func ApplyVATToBookingPayment(ctx context.Context, q db.Querier, paymentID string) {
vatCfg, err := GetVATConfig(ctx, q)
if err != nil {
log.Printf("Failed to read VAT config for payment %s: %v", paymentID, err)
return
}
if !vatCfg.IsVATRegistered {
return
}
// Discount, on_the_house, and tip payments must never have VAT applied.
// Read the payment method and type inside the same transactional context
// so that the just-inserted row is visible (defence against READ
// COMMITTED isolation when q is a pgx.Tx).
var method, ptype string
if qErr := q.QueryRow(ctx, "SELECT payment_method, payment_type FROM payments WHERE id = $1", paymentID).Scan(&method, &ptype); qErr == nil && (method == "discount" || method == "on_the_house" || ptype == "tip") {
return
}
if _, execErr := q.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", paymentID, vatCfg.DefaultVATRate); execErr != nil {
log.Printf("Failed to apply VAT to payment %s: %v", paymentID, execErr)
}
}
// ApplyVATToTillSale reads VAT config and calls apply_vat_to_till_sale for
// SPV gift card till sales. Errors are logged — VAT failure should not
// block the sale flow.
func ApplyVATToTillSale(ctx context.Context, q db.Querier, saleID string) {
vatCfg, err := GetVATConfig(ctx, q)
if err != nil {
log.Printf("Failed to read VAT config for till sale %s: %v", saleID, err)
return
}
if !vatAppliesToVoucher(vatCfg) {
return
}
if _, execErr := q.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", saleID, vatCfg.DefaultVATRate); execErr != nil {
log.Printf("Failed to apply VAT to till sale %s: %v", saleID, execErr)
}
}
// applyVATToChargeRecord reads VAT config and applies VAT to a gift-card
// (voucher) charge record — a payments row (online purchase) or a till_sale
// row (admin till purchase) — under the SPV voucher rule (vatAppliesToVoucher).
// Shared by BuyGiftCard and CreateTillSale so the voucher VAT decision cannot
// drift between the online and till purchase surfaces. Errors are logged —
// VAT failure should not block the purchase/sale flow. isTillSale selects the
// target function (apply_vat_to_till_sale vs apply_vat_to_payment).
func applyVATToChargeRecord(ctx context.Context, q db.Querier, rowID string, isTillSale bool) {
vatCfg, err := GetVATConfig(ctx, q)
if err != nil {
log.Printf("Failed to read VAT config for gift-card charge %s: %v", rowID, err)
return
}
if !vatAppliesToVoucher(vatCfg) {
return
}
fn := "apply_vat_to_payment"
if isTillSale {
fn = "apply_vat_to_till_sale"
}
if _, execErr := q.Exec(ctx, "SELECT "+fn+"($1, $2)", rowID, vatCfg.DefaultVATRate); execErr != nil {
log.Printf("Failed to apply VAT to gift-card charge %s: %v", rowID, execErr)
}
}