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.
142 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|