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