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) } }