Files
Crussell/backend/handlers/payments/vat.go
T
popertots 6d82535780 fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
2026-08-22 00:34:50 +01:00

117 lines
4.7 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)
}
}