Files
Crussell/backend/handlers/payments/vat_test.go
T
popertots 3d337b61b8
CI / Nginx config check (push) Successful in 10s
CI / Docker compose check (push) Successful in 11s
CI / Env docs check (push) Successful in 11s
CI / Frontend major deps (push) Failing after 25s
CI / Frontend deps check (push) Successful in 26s
CI / Secrets scan (push) Successful in 40s
CI / Go build (push) Successful in 40s
CI / Frontend build (push) Successful in 1m8s
CI / Knip (push) Successful in 38s
CI / Go vet (prod) (push) Successful in 1m51s
CI / Frontend a11y check (push) Failing after 2m3s
CI / Go vet (dev) (push) Successful in 2m1s
CI / go mod tidy (push) Successful in 1m24s
CI / Staticcheck (prod) (push) Failing after 3m19s
CI / Staticcheck (dev) (push) Successful in 3m20s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m30s
CI / Frontend QC (audit) (push) Successful in 2m14s
CI / Frontend QC (lint) (push) Failing after 1m5s
CI / Frontend QC (typecheck) (push) Successful in 1m16s
CI / Security scan (dev) (push) Failing after 4m7s
CI / Security scan (prod) (push) Failing after 4m4s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Svelte strict check (push) Has been skipped
fix: remove t.Parallel from VAT tests to eliminate deadlock flakiness
2026-07-10 12:49:57 +01:00

3523 lines
120 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build test && dev
package payments
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
func TestSPV_VATAppliedAtTillSale(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE for SPV till sale")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set for SPV till sale")
}
if vatAmount.Float64 != 8.33 {
t.Errorf("expected vat_amount 8.33, got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set for SPV till sale")
}
if netAmount.Float64 != 41.67 {
t.Errorf("expected net_amount 41.67, got %.2f", netAmount.Float64)
}
}
func TestMPV_NoVATAtTillSale(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for MPV till sale")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for MPV till sale, got %.2f", vatAmount.Float64)
}
if netAmount.Valid {
t.Errorf("expected net_amount to be NULL for MPV till sale, got %.2f", netAmount.Float64)
}
}
func TestMPV_VATAppliedAtRedemption(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
var cardID string
var vtp string
tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&vtp)
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, voucher_type_at_purchase)
VALUES (100.00, 100.00, $1)
RETURNING id
`, vtp).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "giftcard",
"gift_card_id": cardID,
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE for MPV giftcard payment")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set for MPV giftcard payment")
}
if vatAmount.Float64 != 8.33 {
t.Errorf("expected vat_amount 8.33, got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set for MPV giftcard payment")
}
if netAmount.Float64 != 41.67 {
t.Errorf("expected net_amount 41.67, got %.2f", netAmount.Float64)
}
}
func TestSPV_NoVATAtRedemption(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, voucher_type_at_purchase)
VALUES (100.00, 100.00, 'SPV')
RETURNING id
`).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "giftcard",
"gift_card_id": cardID,
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for SPV giftcard payment")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for SPV giftcard payment, got %.2f", vatAmount.Float64)
}
if netAmount.Valid {
t.Errorf("expected net_amount to be NULL for SPV giftcard payment, got %.2f", netAmount.Float64)
}
}
func TestVAT_SkippedWhenNotRegistered(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE when not VAT registered")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL when not VAT registered, got %.2f", vatAmount.Float64)
}
if netAmount.Valid {
t.Errorf("expected net_amount to be NULL when not VAT registered, got %.2f", netAmount.Float64)
}
}
func TestEnableVATRegistration(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = FALSE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, is_vat_applicable, vat_amount, net_amount, created_by, created_at, updated_at)
VALUES ($1, 'full', 'cash', 'completed', 100.00, FALSE, NULL, NULL, $2, NOW(), NOW())
RETURNING id
`, bookingID, adminID).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to insert payment: %v", err)
}
var initialVAT sql.NullFloat64
err = tx.QueryRow(ctx, "SELECT vat_amount FROM payments WHERE id = $1", paymentID).Scan(&initialVAT)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if initialVAT.Valid {
t.Fatal("expected vat_amount to be NULL before enable_vat_registration")
}
var paymentsUpdated, tillSalesUpdated int
err = tx.QueryRow(ctx, "SELECT payments_updated, till_sales_updated FROM enable_vat_registration(CURRENT_DATE, 20.00, 'GB123456789')").Scan(&paymentsUpdated, &tillSalesUpdated)
if err != nil {
t.Fatalf("enable_vat_registration failed: %v", err)
}
if paymentsUpdated < 1 {
t.Errorf("expected at least 1 payment updated, got %d", paymentsUpdated)
}
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, "SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1", paymentID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE after enable_vat_registration")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set after enable_vat_registration")
}
if vatAmount.Float64 != 16.67 {
t.Errorf("expected vat_amount 16.67 (100/1.2*0.2), got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set after enable_vat_registration")
}
if netAmount.Float64 != 83.33 {
t.Errorf("expected net_amount 83.33 (100/1.2), got %.2f", netAmount.Float64)
}
var isRegistered bool
var regNumber *string
err = tx.QueryRow(ctx, "SELECT is_vat_registered, vat_registration_number FROM business_settings WHERE id = 1").Scan(&isRegistered, &regNumber)
if err != nil {
t.Fatalf("failed to query business_settings: %v", err)
}
if !isRegistered {
t.Error("expected is_vat_registered to be TRUE after enable_vat_registration")
}
if regNumber == nil || *regNumber != "GB123456789" {
t.Errorf("expected vat_registration_number 'GB123456789', got %v", regNumber)
}
}
func TestCashBookingPayment_VATApplied(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
// £30 cash payment — with 20% VAT: net=25.00, vat=5.00
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 3000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE for cash payment when VAT registered")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set for cash payment")
}
if vatAmount.Float64 != 5.00 {
t.Errorf("expected vat_amount 5.00 (£30 at 20%%), got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set for cash payment")
}
if netAmount.Float64 != 25.00 {
t.Errorf("expected net_amount 25.00, got %.2f", netAmount.Float64)
}
}
func TestCashBookingPayment_NoVATWhenNotRegistered(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Explicitly not registered (default)
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 3000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var vatAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&isVATApplicable, &vatAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE when not VAT registered")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL when not registered, got %.2f", vatAmount.Float64)
}
}
func TestOnTheHouseTillSale_NoVATEvenIfRegistered(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var vatAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&isVATApplicable, &vatAmount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for on_the_house")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for on_the_house, got %.2f", vatAmount.Float64)
}
}
func TestGetVATConfig(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 5.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
cfg, err := GetVATConfig(ctx, db.Conn)
if err != nil {
t.Fatalf("GetVATConfig failed: %v", err)
}
if !cfg.IsVATRegistered {
t.Error("expected IsVATRegistered to be TRUE")
}
if cfg.DefaultVATRate != 5.00 {
t.Errorf("expected DefaultVATRate 5.00, got %.2f", cfg.DefaultVATRate)
}
if cfg.VoucherType != "MPV" {
t.Errorf("expected VoucherType MPV, got %s", cfg.VoucherType)
}
// Test with FALSE
_, _ = tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = FALSE`)
cfg2, err := GetVATConfig(ctx, db.Conn)
if err != nil {
t.Fatalf("GetVATConfig failed: %v", err)
}
if cfg2.IsVATRegistered {
t.Error("expected IsVATRegistered to be FALSE after update")
}
}
func TestApplyVATToBookingPayment_Direct(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Insert a payment WITHOUT VAT first
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, is_vat_applicable, created_by, created_at, updated_at)
VALUES ($1, 'full', 'cash', 'completed', 60.00, FALSE, $2, NOW(), NOW())
RETURNING id
`, bookingID, adminID).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to insert payment: %v", err)
}
// Apply VAT via the helper — uses db.Conn which routes through transaction context
ApplyVATToBookingPayment(ctx, db.Conn, paymentID)
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, paymentID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE after ApplyVATToBookingPayment")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set")
}
if vatAmount.Float64 != 10.00 {
t.Errorf("expected vat_amount 10.00 (£60 at 20%%), got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set")
}
if netAmount.Float64 != 50.00 {
t.Errorf("expected net_amount 50.00, got %.2f", netAmount.Float64)
}
}
func TestApplyVATToTillSale_Direct(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
// Insert a till_sale WITHOUT VAT first
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', NULL, 'test', 1, 75.00, 75.00, 'cash', 'completed', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to insert till_sale: %v", err)
}
ApplyVATToTillSale(ctx, db.Conn, saleID)
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, saleID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query till_sale: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set")
}
if vatAmount.Float64 != 12.50 {
t.Errorf("expected vat_amount 12.50 (£75 at 20%%), got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set")
}
if netAmount.Float64 != 62.50 {
t.Errorf("expected net_amount 62.50, got %.2f", netAmount.Float64)
}
}
func TestVAT_Idempotency_ApplyTwice(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
// Create till sale via handler (first application of VAT)
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 100.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Read values after first apply
var vat1 sql.NullFloat64
var net1 sql.NullFloat64
err = tx.QueryRow(ctx, `SELECT vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&vat1, &net1)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
// Apply VAT a second time — should be idempotent (AND vat_amount IS NULL guard)
ApplyVATToTillSale(ctx, db.Conn, resp.ID)
var vat2 sql.NullFloat64
var net2 sql.NullFloat64
err = tx.QueryRow(ctx, `SELECT vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&vat2, &net2)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if vat2.Float64 != vat1.Float64 {
t.Errorf("vat_amount changed on second apply: before=%.2f after=%.2f", vat1.Float64, vat2.Float64)
}
if net2.Float64 != net1.Float64 {
t.Errorf("net_amount changed on second apply: before=%.2f after=%.2f", net1.Float64, net2.Float64)
}
}
func TestVAT_DifferentRates(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Test with 5% reduced VAT rate
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 5.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
// Create a till sale — should use 5% rate
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 100.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var vatAmount, netAmount float64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE")
}
// £100 at 5%: net = 100/1.05 = 95.24, vat = 100 - 95.24 = 4.76
if vatAmount != 4.76 {
t.Errorf("expected vat_amount 4.76 (£100 at 5%%), got %.2f", vatAmount)
}
if netAmount != 95.24 {
t.Errorf("expected net_amount 95.24, got %.2f", netAmount)
}
}
func TestPaymentSummary_VATAggregates(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
_, _ = tx.Exec(ctx, "UPDATE bookings SET total_amount = 90.00 WHERE id = $1", bookingID)
// Pay £30 cash — VAT applied via handler
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 3000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+jwt.GenerateTestToken(adminID, "admin"))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Pay another £60 cash
reqBody2, _ := json.Marshal(map[string]interface{}{
"amount": 6000,
"payment_type": "full",
"payment_method": "cash",
})
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody2))
req2.Header.Set("Authorization", "Bearer "+jwt.GenerateTestToken(adminID, "admin"))
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(ctx)
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r2.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String())
}
// Now check PaymentSummary aggregates
svc := NewPaymentService()
summary, err := svc.GetBookingPaymentSummary(ctx, bookingID)
if err != nil {
t.Fatalf("GetBookingPaymentSummary failed: %v", err)
}
if summary.TotalAmount != 90.00 {
t.Errorf("expected TotalAmount 90.00, got %.2f", summary.TotalAmount)
}
if summary.PaidAmount != 90.00 {
t.Errorf("expected PaidAmount 90.00, got %.2f", summary.PaidAmount)
}
// £30 payment: net=25.00, vat=5.00
// £60 payment: net=50.00, vat=10.00
// Total vat: 15.00, Total net: 75.00
if summary.TotalVATAmount != 15.00 {
t.Errorf("expected TotalVATAmount 15.00, got %.2f", summary.TotalVATAmount)
}
if summary.TotalNetAmount != 75.00 {
t.Errorf("expected TotalNetAmount 75.00, got %.2f", summary.TotalNetAmount)
}
if summary.RemainingAmount != 0.00 {
t.Errorf("expected RemainingAmount 0.00, got %.2f", summary.RemainingAmount)
}
}
func TestBuyGiftCard_SPV_VATApplied(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateTestToken(userID, "verified_email")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": "idempotency-buy-gc-vat-test",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
var paymentAmount float64
err = tx.QueryRow(ctx, `
SELECT p.amount, p.is_vat_applicable, p.vat_amount, p.net_amount
FROM payments p
WHERE p.created_by = $1
ORDER BY p.created_at DESC LIMIT 1
`, userID).Scan(&paymentAmount, &isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if paymentAmount != 20.00 {
t.Errorf("expected amount 20.00, got %.2f", paymentAmount)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE for SPV gift card purchase")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set")
}
if vatAmount.Float64 != 3.33 {
t.Errorf("expected vat_amount 3.33 (£20 at 20%%), got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set")
}
if netAmount.Float64 != 16.67 {
t.Errorf("expected net_amount 16.67, got %.2f", netAmount.Float64)
}
var balance float64
err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query balance: %v", err)
}
if balance != 20.00 {
t.Errorf("expected balance 20.00, got %.2f", balance)
}
}
func TestVAT_ToggleLifecycle(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = FALSE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to set initial state: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
_, _ = tx.Exec(ctx, "UPDATE bookings SET total_amount = 200.00 WHERE id = $1", bookingID)
// Phase 1: Not VAT registered — pay £50 cash
reqBody1, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "cash",
})
req1 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody1))
req1.Header.Set("Authorization", "Bearer "+token)
req1.Header.Set("Content-Type", "application/json")
req1 = req1.WithContext(ctx)
w1 := httptest.NewRecorder()
r1 := chi.NewRouter()
r1.Use(mw.RequireAuth)
r1.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r1.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("phase 1: expected 200, got %d: %s", w1.Code, w1.Body.String())
}
// Verify no VAT on phase 1 payment
var phase1VAT sql.NullFloat64
var phase1VATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'cash' AND amount = 50.00`, bookingID).Scan(&phase1VATApplicable, &phase1VAT)
if err != nil {
t.Fatalf("phase 1: failed to query payment: %v", err)
}
if phase1VATApplicable {
t.Error("phase 1: expected no VAT when not registered")
}
if phase1VAT.Valid {
t.Errorf("phase 1: expected NULL vat_amount, got %.2f", phase1VAT.Float64)
}
// Phase 2: Enable VAT registration
_, err = tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE`)
if err != nil {
t.Fatalf("phase 2: failed to enable VAT: %v", err)
}
// Phase 3: Pay another £50 cash — should have VAT
reqBody3, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "cash",
})
req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3))
req3.Header.Set("Authorization", "Bearer "+token)
req3.Header.Set("Content-Type", "application/json")
req3 = req3.WithContext(ctx)
w3 := httptest.NewRecorder()
r3 := chi.NewRouter()
r3.Use(mw.RequireAuth)
r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r3.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("phase 3: expected 200, got %d: %s", w3.Code, w3.Body.String())
}
var phase3Resp CheckoutResponse
if err := json.Unmarshal(w3.Body.Bytes(), &phase3Resp); err != nil {
t.Fatalf("phase 3: failed to parse response: %v", err)
}
var phase3VAT sql.NullFloat64
var phase3Net sql.NullFloat64
var phase3VATApplicable bool
var phase3Amount float64
// Get the specific payment by its ID from the response (avoiding
// non-deterministic ORDER BY created_at DESC when NOW() returns the same
// transaction start time for all inserts within a transaction).
err = tx.QueryRow(ctx, `SELECT amount, is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, phase3Resp.CheckoutID).Scan(&phase3Amount, &phase3VATApplicable, &phase3VAT, &phase3Net)
if err != nil {
t.Fatalf("phase 3: failed to query payment: %v", err)
}
if !phase3VATApplicable {
t.Error("phase 3: expected VAT to be applied after registration")
}
if !phase3VAT.Valid {
t.Fatal("phase 3: expected vat_amount to be set")
}
if phase3VAT.Float64 != 8.33 {
t.Errorf("phase 3: expected vat_amount 8.33, got %.2f", phase3VAT.Float64)
}
if !phase3Net.Valid {
t.Fatal("phase 3: expected net_amount to be set")
}
if phase3Net.Float64 != 41.67 {
t.Errorf("phase 3: expected net_amount 41.67, got %.2f", phase3Net.Float64)
}
// Phase 4: Verify PaymentSummary reflects both payments
svc := NewPaymentService()
summary, err := svc.GetBookingPaymentSummary(ctx, bookingID)
if err != nil {
t.Fatalf("phase 4: GetBookingPaymentSummary failed: %v", err)
}
if summary.PaidAmount != 100.00 {
t.Errorf("phase 4: expected PaidAmount 100.00, got %.2f", summary.PaidAmount)
}
if summary.TotalVATAmount != 8.33 {
t.Errorf("phase 4: expected TotalVATAmount 8.33 (only 2nd payment has VAT), got %.2f", summary.TotalVATAmount)
}
if summary.TotalNetAmount != 91.67 {
t.Errorf("phase 4: expected TotalNetAmount 91.67 (50 + 41.67), got %.2f", summary.TotalNetAmount)
}
}
func TestVAT_Refund_VATInclusiveCashPayment(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID := userID
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
// Create a £50 cash payment with VAT via the handler
token := jwt.GenerateTestToken(adminID, "admin")
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("payment handler: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify payment has VAT
var vatAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&isVATApplicable, &vatAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if !isVATApplicable {
t.Fatal("expected payment to have VAT before refund test")
}
// Now cancel the booking (>72h before = full refund)
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, pErr := ProcessCancellationRefund(ctx, bookingID, 100, 50, start, now, "client_cancelled", &userID)
if pErr != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", pErr)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
// Verify refund record created — amount should be the gross £50
var refundAmount float64
err = tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM refunds WHERE booking_id = $1`, bookingID).Scan(&refundAmount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundAmount != 50.00 {
t.Errorf("expected refund amount 50.00 (gross, including VAT), got %.2f", refundAmount)
}
}
func TestSPV_FullLifecycle_BuyAndRedeem(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
// Phase 1: Buy gift card via till sale (cash) — SPV, so VAT at sale
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 100.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("till sale: expected 201, got %d: %s", w.Code, w.Body.String())
}
var tsResp TillSaleResponse
json.NewDecoder(w.Body).Decode(&tsResp)
// Verify till_sale has VAT applied
var tsVAT sql.NullFloat64
var tsNet sql.NullFloat64
var tsVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT, &tsNet)
if !tsVATApplicable {
t.Error("SPV: expected till_sale VAT applicable")
}
if !tsVAT.Valid || tsVAT.Float64 != 16.67 {
t.Errorf("SPV: expected till_sale vat 16.67, got %.2f", tsVAT.Float64)
}
// Get the gift card ID from the till sale
var cardID string
err = tx.QueryRow(ctx, "SELECT item_id FROM till_sales WHERE id = $1", tsResp.ID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to get gift card ID: %v", err)
}
// Phase 2: Create a booking and redeem the gift card
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
// Redeem £50 via gift card
redeemBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "giftcard",
"gift_card_id": cardID,
})
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(ctx)
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r2.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
// Verify NO VAT on redemption (SPV — VAT already at sale)
var payVAT sql.NullFloat64
var payVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT)
if payVATApplicable {
t.Error("SPV redemption: expected NO VAT at redemption")
}
if payVAT.Valid {
t.Errorf("SPV redemption: expected NULL vat, got %.2f", payVAT.Float64)
}
// Verify gift card balance
var remaining float64
tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining)
if remaining != 50.00 {
t.Errorf("expected remaining balance 50.00, got %.2f", remaining)
}
}
func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
// Phase 1: Buy gift card via till sale (cash) — MPV, so NO VAT at sale
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 100.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("till sale: expected 201, got %d: %s", w.Code, w.Body.String())
}
var tsResp TillSaleResponse
json.NewDecoder(w.Body).Decode(&tsResp)
// Verify till_sale has NO VAT (MPV)
var tsVAT sql.NullFloat64
var tsVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT)
if tsVATApplicable {
t.Error("MPV: expected NO till_sale VAT")
}
if tsVAT.Valid {
t.Errorf("MPV: expected NULL vat_amount at sale, got %.2f", tsVAT.Float64)
}
// Get the gift card ID
var cardID string
err = tx.QueryRow(ctx, "SELECT item_id FROM till_sales WHERE id = $1", tsResp.ID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to get gift card ID: %v", err)
}
// Phase 2: Create a booking and redeem the gift card — MPV so VAT at redemption
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
redeemBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "giftcard",
"gift_card_id": cardID,
})
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(ctx)
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r2.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
// Verify VAT IS applied at redemption (MPV)
var payVAT sql.NullFloat64
var payNet sql.NullFloat64
var payVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT, &payNet)
if !payVATApplicable {
t.Error("MPV redemption: expected VAT at redemption")
}
if !payVAT.Valid {
t.Fatal("MPV redemption: expected vat_amount to be set")
}
if payVAT.Float64 != 8.33 {
t.Errorf("MPV redemption: expected vat 8.33, got %.2f", payVAT.Float64)
}
if !payNet.Valid {
t.Fatal("MPV redemption: expected net_amount to be set")
}
if payNet.Float64 != 41.67 {
t.Errorf("MPV redemption: expected net 41.67, got %.2f", payNet.Float64)
}
}
func TestVAT_TopupGiftCard_NoVATOnTopup(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
token := jwt.GenerateTestToken(adminID, "admin")
// Create a gift card first
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining)
VALUES (50.00, 50.00) RETURNING id
`).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
// Top up via till sale (cash) — should apply VAT (SPV)
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
GiftCardID: &cardID,
Amount: 25.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("topup: expected 201, got %d: %s", w.Code, w.Body.String())
}
var tsResp TillSaleResponse
json.NewDecoder(w.Body).Decode(&tsResp)
// Verify till_sale has VAT applied
var vatAmount sql.NullFloat64
var isVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&isVATApplicable, &vatAmount)
if !isVATApplicable {
t.Error("expected VAT on topup till sale (SPV)")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount on topup")
}
// £25 at 20%: vat = 25 - (25/1.2) = 25 - 20.83 = 4.17
if vatAmount.Float64 != 4.17 {
t.Errorf("expected vat_amount 4.17, got %.2f", vatAmount.Float64)
}
// Verify gift card balance increased
var remaining float64
tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining)
if remaining != 75.00 {
t.Errorf("expected remaining 75.00, got %.2f", remaining)
}
}
func TestVAT_RemainingBalanceWithVAT(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
_, _ = tx.Exec(ctx, "UPDATE bookings SET total_amount = 100.00 WHERE id = $1", bookingID)
// Pay £30 cash with VAT
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 3000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Remaining balance should be total - paid (gross) = 100 - 30 = 70
svc := NewPaymentService()
remaining, rErr := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
if rErr != nil {
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr)
}
if remaining != 7000 {
t.Errorf("expected remaining 7000 cents (£70), got %d", remaining)
}
// Pay another £40 with VAT — remaining should be 100 - 70 = 30
reqBody2, _ := json.Marshal(map[string]interface{}{
"amount": 4000,
"payment_type": "full",
"payment_method": "cash",
})
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody2))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(ctx)
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r2.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second payment: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
remaining2, rErr2 := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
if rErr2 != nil {
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr2)
}
if remaining2 != 3000 {
t.Errorf("expected remaining 3000 cents (£30), got %d", remaining2)
}
// Pay the final £30 — remaining should be 0
reqBody3, _ := json.Marshal(map[string]interface{}{
"amount": 3000,
"payment_type": "full",
"payment_method": "cash",
})
req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3))
req3.Header.Set("Authorization", "Bearer "+token)
req3.Header.Set("Content-Type", "application/json")
req3 = req3.WithContext(ctx)
w3 := httptest.NewRecorder()
r3 := chi.NewRouter()
r3.Use(mw.RequireAuth)
r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r3.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("third payment: expected 200, got %d: %s", w3.Code, w3.Body.String())
}
remaining3, rErr3 := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
if rErr3 != nil {
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr3)
}
if remaining3 != 0 {
t.Errorf("expected remaining 0, got %d", remaining3)
}
}
func TestVAT_GiftCardCRUD_DoesNotInterfere(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create two gift cards in DB
var card1ID, card2ID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (100.00, 100.00, $1) RETURNING id
`, adminID).Scan(&card1ID)
if err != nil {
t.Fatalf("failed to create card 1: %v", err)
}
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (50.00, 50.00, $1) RETURNING id
`, adminID).Scan(&card2ID)
if err != nil {
t.Fatalf("failed to create card 2: %v", err)
}
// 1) Transfer between cards (card1→card2) — unaffected by VAT
transferBody, _ := json.Marshal(map[string]interface{}{
"to_card_id": card2ID,
"amount": 30.00,
})
req1 := httptest.NewRequest("POST", "/api/admin/gift-cards/"+card1ID+"/transfer", bytes.NewBuffer(transferBody))
req1.Header.Set("Authorization", "Bearer "+adminToken)
req1.Header.Set("Content-Type", "application/json")
req1 = req1.WithContext(ctx)
w1 := httptest.NewRecorder()
r1 := chi.NewRouter()
r1.Use(mw.RequireAuth)
r1.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
r1.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("transfer: expected 200, got %d: %s", w1.Code, w1.Body.String())
}
// Verify card balances after transfer
var c1Bal, c2Bal float64
tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", card1ID).Scan(&c1Bal)
tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", card2ID).Scan(&c2Bal)
if c1Bal != 70.00 || c2Bal != 80.00 {
t.Errorf("expected card1=70.00 card2=80.00, got card1=%.2f card2=%.2f", c1Bal, c2Bal)
}
// 2) Get gift cards list — should work with VAT fields present on payments
getReq := httptest.NewRequest("GET", "/api/admin/giftcards?limit=10&offset=0", nil)
getReq.Header.Set("Authorization", "Bearer "+adminToken)
getReq = getReq.WithContext(ctx)
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Get("/api/admin/giftcards", GetGiftCards)
r2.ServeHTTP(w2, getReq)
if w2.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
// 3) Get individual gift card — should work
getOne := httptest.NewRequest("GET", "/api/admin/giftcards/"+card1ID, nil)
getOne.Header.Set("Authorization", "Bearer "+adminToken)
getOne = getOne.WithContext(ctx)
w3 := httptest.NewRecorder()
r3 := chi.NewRouter()
r3.Use(mw.RequireAuth)
r3.Get("/api/admin/giftcards/{id}", GetGiftCards)
r3.ServeHTTP(w3, getOne)
if w3.Code != http.StatusOK {
t.Fatalf("get one: expected 200, got %d: %s", w3.Code, w3.Body.String())
}
}
// ─── Additional tests ──────────────────────────────────────────────────────────
// TestMPV_Topup_NoVAT verifies a gift card topup with voucher_type=MPV does not
// apply VAT at sale.
func TestMPV_Topup_NoVAT(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
token := jwt.GenerateTestToken(adminID, "admin")
// Create a gift card first
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining)
VALUES (50.00, 50.00) RETURNING id
`).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
// Top up via till sale (cash) — MPV, so no VAT
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
GiftCardID: &cardID,
Amount: 25.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("topup: expected 201, got %d: %s", w.Code, w.Body.String())
}
var tsResp TillSaleResponse
json.NewDecoder(w.Body).Decode(&tsResp)
// Verify NO VAT on MPV topup
var vatAmount sql.NullFloat64
var isVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&isVATApplicable, &vatAmount)
if isVATApplicable {
t.Error("expected no VAT on MPV topup")
}
if vatAmount.Valid {
t.Errorf("expected NULL vat_amount on MPV topup, got %.2f", vatAmount.Float64)
}
// Verify gift card balance still increased
var remaining float64
tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining)
if remaining != 75.00 {
t.Errorf("expected remaining 75.00, got %.2f", remaining)
}
}
// TestCreateBookingPayment_VATApplied verifies that the online Square payment
// path (CreateBookingPayment) applies VAT when the business is registered.
// Uses a booking that has already started so buildSplitRecords returns a
// single payment record, making VAT assertion straightforward.
func TestCreateBookingPayment_VATApplied(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Use a past start time so buildSplitRecords returns a single record
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:vat-online-card"
req := CreateBookingPaymentRequest{
Amount: 3000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "booking-vat-online-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify VAT applied to the single payment record
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE for online payment when VAT registered")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set")
}
// £30 at 20%: net = 30/1.2 = 25.00, vat = 30 - 25 = 5.00
if vatAmount.Float64 != 5.00 {
t.Errorf("expected vat_amount 5.00, got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set")
}
if netAmount.Float64 != 25.00 {
t.Errorf("expected net_amount 25.00, got %.2f", netAmount.Float64)
}
}
// TestTillSale_SavedCard_SPV_VATApplied verifies that a till sale with
// saved_card payment method applies VAT for SPV.
func TestTillSale_SavedCard_SPV_VATApplied(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a saved card for the admin
cardID, err := fixtures.CreateTestPaymentMethod(tx, adminID, "ccof:saved-card-test", "VISA", "1111")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 75.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &adminID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
json.NewDecoder(w.Body).Decode(&resp)
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE for SPV saved_card till sale")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set")
}
// £75 at 20%: vat = 75 - (75/1.2) = 75 - 62.50 = 12.50
if vatAmount.Float64 != 12.50 {
t.Errorf("expected vat_amount 12.50, got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set")
}
if netAmount.Float64 != 62.50 {
t.Errorf("expected net_amount 62.50, got %.2f", netAmount.Float64)
}
}
// TestVoucherToggle_SPVPurchase_MPVRedeem verifies that a gift card bought
// as SPV (VAT at sale) and then redeemed after switching to MPV does NOT
// get double-taxed. The voucher_type_at_purchase stored on the card at
// creation time is used at redemption, not the current business_settings.
func TestVoucherToggle_SPVPurchase_MPVRedeem(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
// Phase 1: Buy gift card as SPV — VAT at sale
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 100.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("till sale: expected 201, got %d: %s", w.Code, w.Body.String())
}
var tsResp TillSaleResponse
json.NewDecoder(w.Body).Decode(&tsResp)
// Verify VAT applied at sale (SPV) and voucher_type_at_purchase stored
var tsVAT sql.NullFloat64
var tsVATApplicable bool
var storedVTP sql.NullString
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT)
if !tsVATApplicable {
t.Error("SPV purchase: expected VAT at sale")
}
var cardID string
err = tx.QueryRow(ctx, "SELECT item_id FROM till_sales WHERE id = $1", tsResp.ID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to get gift card ID: %v", err)
}
err = tx.QueryRow(ctx, "SELECT voucher_type_at_purchase FROM gift_cards WHERE id = $1", cardID).Scan(&storedVTP)
if err != nil {
t.Fatalf("failed to get voucher_type_at_purchase from gift card: %v", err)
}
if !storedVTP.Valid || storedVTP.String != "SPV" {
t.Errorf("expected voucher_type_at_purchase 'SPV', got %v", storedVTP)
}
// Phase 2: Toggle voucher_type to MPV — should NOT affect already-purchased cards
_, err = tx.Exec(ctx, `UPDATE business_settings SET voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to toggle to MPV: %v", err)
}
// Phase 3: Redeem — stored voucher_type is SPV, so NO VAT at redemption
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
redeemBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "giftcard",
"gift_card_id": cardID,
})
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(ctx)
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r2.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var payVAT sql.NullFloat64
var payVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT)
if payVATApplicable {
t.Error("expected NO VAT at redemption (card stored SPV, VAT already paid at sale)")
}
if payVAT.Valid {
t.Errorf("expected NULL vat at SPV-stored redemption, got %.2f", payVAT.Float64)
}
}
// TestApplyVATToTillSale_MPV verifies ApplyVATToTillSale has no effect when
// voucher_type is MPV.
func TestApplyVATToTillSale_MPV(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', NULL, 'test', 1, 50.00, 50.00, 'cash', 'completed', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to insert till_sale: %v", err)
}
ApplyVATToTillSale(ctx, db.Conn, saleID)
var vatAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, saleID).Scan(&isVATApplicable, &vatAmount)
if err != nil {
t.Fatalf("failed to query till_sale: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for MPV")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for MPV, got %.2f", vatAmount.Float64)
}
}
// ─── Exhaustive voucher_type toggle + legacy tests ─────────────────────────
// TestVoucherToggle_MPVPurchase_SPVRedeem verifies that a gift card bought
// as MPV (no VAT at sale, deferred to redemption) and then redeemed after
// switching to SPV still gets VAT at redemption — because the stored
// voucher_type_at_purchase is MPV, overriding the current business_settings.
func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
// Phase 1: Buy gift card as MPV — no VAT at sale
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 100.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("till sale: expected 201, got %d: %s", w.Code, w.Body.String())
}
var tsResp TillSaleResponse
json.NewDecoder(w.Body).Decode(&tsResp)
var tsVAT sql.NullFloat64
var tsVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT)
if tsVATApplicable {
t.Error("MPV purchase: expected NO VAT at sale")
}
var cardID string
var storedVTP sql.NullString
err = tx.QueryRow(ctx, "SELECT item_id FROM till_sales WHERE id = $1", tsResp.ID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to get card ID: %v", err)
}
tx.QueryRow(ctx, "SELECT voucher_type_at_purchase FROM gift_cards WHERE id = $1", cardID).Scan(&storedVTP)
if !storedVTP.Valid || storedVTP.String != "MPV" {
t.Errorf("expected voucher_type_at_purchase 'MPV', got %v", storedVTP)
}
// Phase 2: Toggle to SPV — should NOT affect already-purchased cards
_, err = tx.Exec(ctx, `UPDATE business_settings SET voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to toggle to SPV: %v", err)
}
// Phase 3: Redeem — stored voucher_type is MPV, so VAT IS applied at redemption
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
redeemBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "giftcard",
"gift_card_id": cardID,
})
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(ctx)
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r2.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var payVAT sql.NullFloat64
var payVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT)
if !payVATApplicable {
t.Error("expected VAT at redemption (card stored MPV)")
}
if !payVAT.Valid {
t.Fatal("expected vat_amount to be set at MPV-stored redemption")
}
if payVAT.Float64 != 8.33 {
t.Errorf("expected vat 8.33, got %.2f", payVAT.Float64)
}
}
// TestLegacyGiftCard_NullVoucherType defaults NULL voucher_type_at_purchase
// to SPV behavior — no VAT at redemption.
func TestLegacyGiftCard_NullVoucherType(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
// Create a gift card WITHOUT voucher_type_at_purchase (legacy card, NULL)
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining)
VALUES (50.00, 50.00)
RETURNING id
`).Scan(&cardID)
if err != nil {
t.Fatalf("failed to create legacy gift card: %v", err)
}
// Redeem — legacy NULL defaults to SPV, so no VAT at redemption
redeemBody, _ := json.Marshal(map[string]interface{}{
"amount": 3000,
"payment_type": "full",
"payment_method": "giftcard",
"gift_card_id": cardID,
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var payVAT sql.NullFloat64
var payVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT)
if payVATApplicable {
t.Error("expected NO VAT for legacy card (NULL defaults to SPV)")
}
if payVAT.Valid {
t.Errorf("expected NULL vat for legacy card, got %.2f", payVAT.Float64)
}
}
// TestUsedBalance_NoVATRegardless verifies that when payment comes from a
// user's account balance (usedBalance=true), no VAT is ever applied —
// it was already paid when the card was originally purchased.
func TestUsedBalance_NoVATRegardless(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Set MPV — even with MPV, usedBalance should NOT have VAT
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", userID)
token := jwt.GenerateTestToken(userID, "admin")
// Give the user an account balance (as if they redeemed a gift card)
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, 100.00, NOW())
ON CONFLICT (user_id) DO UPDATE SET balance = 100.00, updated_at = NOW()
`, userID)
if err != nil {
t.Fatalf("failed to set user balance: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
// Pay £50 from account balance (no GiftCardID — uses user balance)
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "giftcard",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var payVAT sql.NullFloat64
var payVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT)
if payVATApplicable {
t.Error("expected NO VAT for usedBalance (already paid at purchase)")
}
if payVAT.Valid {
t.Errorf("expected NULL vat for usedBalance, got %.2f", payVAT.Float64)
}
// Verify balance was deducted
var balance float64
tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if balance != 50.00 {
t.Errorf("expected balance 50.00 after payment, got %.2f", balance)
}
}
// ─── VAT + discount interaction ──────────────────────────────────────────────
// TestVAT_DiscountPayment_NoVAT verifies that discount payments never get VAT
// applied, even when the business is VAT-registered.
func TestVAT_DiscountPayment_NoVAT(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
_, _ = tx.Exec(ctx, "UPDATE bookings SET total_amount = 100.00 WHERE id = $1", bookingID)
// Pay £80 cash first (this creates the completed payment to build records against)
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 8000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("cash payment: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Now add a discount payment directly (handlers.go doesn't expose a
// discount endpoint — the till used to handle it, so we insert it via
// the service layer)
discountRecord := PaymentRecord{
BookingID: bookingID,
PaymentType: "full",
PaymentMethod: "discount",
Status: "completed",
Amount: 10.00,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &adminID,
}
svc := NewPaymentService()
discountID, err := svc.CreatePaymentRecord(ctx, discountRecord, nil)
if err != nil {
t.Fatalf("failed to create discount payment: %v", err)
}
// Apply VAT to the discount payment — should be a no-op
ApplyVATToBookingPayment(ctx, tx, discountID)
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, discountID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query discount payment: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for discount payment")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for discount, got %.2f", vatAmount.Float64)
}
// Verify PaymentSummary excludes VAT from discount payments
summary, err := svc.GetBookingPaymentSummary(ctx, bookingID)
if err != nil {
t.Fatalf("GetBookingPaymentSummary failed: %v", err)
}
// £80 cash payment: net=66.67, vat=13.33
// £10 discount: no VAT, net=10.00 (discount amount is the net)
if summary.TotalVATAmount != 13.33 {
t.Errorf("expected TotalVATAmount 13.33, got %.2f", summary.TotalVATAmount)
}
if summary.TotalNetAmount != 76.67 {
t.Errorf("expected TotalNetAmount 76.67 (66.67 cash net + 10.00 discount), got %.2f", summary.TotalNetAmount)
}
if summary.PaidAmount != 90.00 {
t.Errorf("expected PaidAmount 90.00 (80 cash + 10 discount), got %.2f", summary.PaidAmount)
}
}
// ─── VAT + split payments ────────────────────────────────────────────────────
// TestVAT_SplitPayments verifies VAT is correctly partitioned across split
// records when a single Square payment is split into deposit + balance.
func TestVAT_SplitPayments(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
userID := adminID
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a booking far in the future (so buildSplitRecords splits into
// deposit + balance, not a single record)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
// Get the booking total
var totalAmount float64
err = tx.QueryRow(ctx, "SELECT total_amount FROM bookings WHERE id = $1", bookingID).Scan(&totalAmount)
if err != nil {
t.Fatalf("failed to get booking total: %v", err)
}
// Process a Square payment via the online handler — this triggers
// buildSplitRecords which creates split deposit + balance records
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:split-vat-card"
req := CreateBookingPaymentRequest{
Amount: int64(totalAmount * 100),
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "split-vat-test-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Query all payment records for this booking
rows, err := tx.Query(ctx, `
SELECT payment_type, amount, is_vat_applicable, vat_amount, net_amount
FROM payments
WHERE booking_id = $1
ORDER BY created_at ASC
`, bookingID)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
defer rows.Close()
var totalVAT, totalNet, totalAmountPaid float64
paymentCount := 0
for rows.Next() {
var ptype string
var amount float64
var isVAT bool
var vat, net sql.NullFloat64
if err := rows.Scan(&ptype, &amount, &isVAT, &vat, &net); err != nil {
t.Fatalf("failed to scan payment: %v", err)
}
paymentCount++
totalAmountPaid += amount
if isVAT {
if !vat.Valid {
t.Errorf("split record %s (%.2f): expected vat_amount to be set", ptype, amount)
} else {
totalVAT += vat.Float64
}
if !net.Valid {
t.Errorf("split record %s (%.2f): expected net_amount to be set", ptype, amount)
} else {
totalNet += net.Float64
}
}
}
if paymentCount < 2 {
t.Errorf("expected at least 2 split payment records, got %d", paymentCount)
}
// Total VAT should equal Total - Net across all split records
if totalNet+totalVAT != totalAmountPaid {
t.Errorf("net(%.2f) + vat(%.2f) = %.2f, expected %.2f", totalNet, totalVAT, totalNet+totalVAT, totalAmountPaid)
}
// Verify each split has proportional VAT
if totalVAT < 0.01 {
t.Error("expected non-zero total VAT across split payments")
}
}
// TestEnableVATRegistration_TillSales verifies that enable_vat_registration
// also retroactively applies VAT to completed till_sales records.
func TestEnableVATRegistration_TillSales(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
// Create a till_sale without VAT (simulating a sale before VAT registration)
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', NULL, 'test', 1, 50.00, 50.00, 'cash', 'completed', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to insert till_sale: %v", err)
}
// Verify no VAT before
var initialVAT sql.NullFloat64
tx.QueryRow(ctx, "SELECT vat_amount FROM till_sales WHERE id = $1", saleID).Scan(&initialVAT)
if initialVAT.Valid {
t.Fatal("expected vat_amount to be NULL before enable_vat_registration")
}
// Enable VAT retroactively
var tillSalesUpdated int
err = tx.QueryRow(ctx, "SELECT till_sales_updated FROM enable_vat_registration(CURRENT_DATE, 20.00, 'GB123456789')").Scan(&tillSalesUpdated)
if err != nil {
t.Fatalf("enable_vat_registration failed: %v", err)
}
if tillSalesUpdated < 1 {
t.Errorf("expected at least 1 till_sale updated, got %d", tillSalesUpdated)
}
// Verify till_sale now has VAT
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, "SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1", saleID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query till_sale: %v", err)
}
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE after enable_vat_registration")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set after enable_vat_registration")
}
if vatAmount.Float64 != 8.33 {
t.Errorf("expected vat_amount 8.33 (50/1.2*0.2), got %.2f", vatAmount.Float64)
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set")
}
if netAmount.Float64 != 41.67 {
t.Errorf("expected net_amount 41.67 (50/1.2), got %.2f", netAmount.Float64)
}
}
func TestGetVATConfig_WithTxQuerier(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 5.00, voucher_type = 'MPV'`)
if err != nil {
t.Fatalf("failed to set VAT config: %v", err)
}
// Read via explicit tx querier — must see uncommitted changes.
cfg, err := GetVATConfig(ctx, tx)
if err != nil {
t.Fatalf("GetVATConfig with tx querier failed: %v", err)
}
if !cfg.IsVATRegistered {
t.Error("expected IsVATRegistered to be TRUE (read via tx)")
}
if cfg.DefaultVATRate != 5.00 {
t.Errorf("expected DefaultVATRate 5.00, got %.2f", cfg.DefaultVATRate)
}
if cfg.VoucherType != "MPV" {
t.Errorf("expected VoucherType MPV, got %s", cfg.VoucherType)
}
}
func TestGetVATConfig_DbConnQuerier(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 8.00, voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to set VAT config: %v", err)
}
// Read via db.Conn — in tests, PoolProxy routes through the context tx,
// so this should also see the uncommitted changes.
cfg, err := GetVATConfig(ctx, db.Conn)
if err != nil {
t.Fatalf("GetVATConfig with db.Conn querier failed: %v", err)
}
if !cfg.IsVATRegistered {
t.Error("expected IsVATRegistered to be TRUE (read via db.Conn)")
}
if cfg.DefaultVATRate != 8.00 {
t.Errorf("expected DefaultVATRate 8.00, got %.2f", cfg.DefaultVATRate)
}
if cfg.VoucherType != "SPV" {
t.Errorf("expected VoucherType SPV, got %s", cfg.VoucherType)
}
}
func TestApplyVATToBookingPayment_DefensiveCheck(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to set VAT config: %v", err)
}
// Create a discount payment record directly.
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_by, created_at, updated_at)
VALUES ($1, 'full', 'discount', 'completed', 10.00, $2, NOW(), NOW())
RETURNING id
`, bookingID, adminID).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create discount payment: %v", err)
}
// This should skip VAT because payment_method == 'discount'.
ApplyVATToBookingPayment(ctx, tx, paymentID)
var isVATApplicable bool
var vatAmount sql.NullFloat64
err = tx.QueryRow(ctx, "SELECT is_vat_applicable, vat_amount FROM payments WHERE id = $1", paymentID).Scan(&isVATApplicable, &vatAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for discount payment (defensive check)")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for discount payment, got %.2f", vatAmount.Float64)
}
}
func TestApplyVATToBookingPayment_OnTheHouse_Skip(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to set VAT config: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_by, created_at, updated_at)
VALUES ($1, 'full', 'on_the_house', 'completed', 25.00, $2, NOW(), NOW())
RETURNING id
`, bookingID, adminID).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create on_the_house payment: %v", err)
}
// The defensive check reads the payment method inside the same transaction
// via the tx querier, which must see the just-inserted row.
ApplyVATToBookingPayment(ctx, tx, paymentID)
var isVATApplicable bool
var vatAmount sql.NullFloat64
err = tx.QueryRow(ctx, "SELECT is_vat_applicable, vat_amount FROM payments WHERE id = $1", paymentID).Scan(&isVATApplicable, &vatAmount)
if err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for on_the_house payment")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for on_the_house, got %.2f", vatAmount.Float64)
}
}
// ─── VAT disable/enable lifecycle ──────────────────────────────────────────
// TestVAT_DisableVATRegistration_Lifecycle verifies that disabling VAT stops
// subsequent payments from having VAT applied, and that PaymentSummary only
// reflects VAT from payments made while VAT was enabled.
func TestVAT_DisableVATRegistration_Lifecycle(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to enable VAT: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
_, _ = tx.Exec(ctx, "UPDATE bookings SET total_amount = 100.00 WHERE id = $1", bookingID)
// Phase 1: Pay £50 cash with VAT enabled — VAT should be applied
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("phase 1: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify VAT applied on phase 1 payment
var vat1 sql.NullFloat64
var net1 sql.NullFloat64
var vatApplicable1 bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'cash' AND amount = 50.00`, bookingID).Scan(&vatApplicable1, &vat1, &net1)
if err != nil {
t.Fatalf("phase 1: failed to query payment: %v", err)
}
if !vatApplicable1 {
t.Error("phase 1: expected is_vat_applicable TRUE when VAT is enabled")
}
if !vat1.Valid {
t.Fatal("phase 1: expected vat_amount to be set")
}
if vat1.Float64 != 8.33 {
t.Errorf("phase 1: expected vat_amount 8.33 (£50 at 20%%), got %.2f", vat1.Float64)
}
if !net1.Valid {
t.Fatal("phase 1: expected net_amount to be set")
}
if net1.Float64 != 41.67 {
t.Errorf("phase 1: expected net_amount 41.67 (£50/1.2), got %.2f", net1.Float64)
}
// Phase 2: Disable VAT
_, err = tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = FALSE`)
if err != nil {
t.Fatalf("phase 2: failed to disable VAT: %v", err)
}
// Phase 3: Pay another £50 cash — should have NO VAT
reqBody3, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "cash",
})
req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3))
req3.Header.Set("Authorization", "Bearer "+token)
req3.Header.Set("Content-Type", "application/json")
req3 = req3.WithContext(ctx)
w3 := httptest.NewRecorder()
r3 := chi.NewRouter()
r3.Use(mw.RequireAuth)
r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r3.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("phase 3: expected 200, got %d: %s", w3.Code, w3.Body.String())
}
var phase3Resp CheckoutResponse
if err := json.Unmarshal(w3.Body.Bytes(), &phase3Resp); err != nil {
t.Fatalf("phase 3: failed to parse response: %v", err)
}
// Verify NO VAT on the second payment (query by specific payment ID
// avoids non-deterministic ORDER BY created_at DESC when all payments
// share the same transaction start time from NOW()).
var vat3 sql.NullFloat64
var net3 sql.NullFloat64
var vatApplicable3 bool
var amount3 float64
err = tx.QueryRow(ctx, `SELECT amount, is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, phase3Resp.CheckoutID).Scan(&amount3, &vatApplicable3, &vat3, &net3)
if err != nil {
t.Fatalf("phase 3: failed to query payment: %v", err)
}
if vatApplicable3 {
t.Error("phase 3: expected is_vat_applicable FALSE after VAT disabled")
}
if vat3.Valid {
t.Errorf("phase 3: expected vat_amount NULL after disabling VAT, got %.2f", vat3.Float64)
}
// Phase 4: Verify PaymentSummary only includes VAT from the first payment
svc := NewPaymentService()
summary, err := svc.GetBookingPaymentSummary(ctx, bookingID)
if err != nil {
t.Fatalf("phase 4: GetBookingPaymentSummary failed: %v", err)
}
if summary.PaidAmount != 100.00 {
t.Errorf("phase 4: expected PaidAmount 100.00 (50 + 50), got %.2f", summary.PaidAmount)
}
if summary.TotalVATAmount != 8.33 {
t.Errorf("phase 4: expected TotalVATAmount 8.33 (only first payment has VAT), got %.2f", summary.TotalVATAmount)
}
// First payment net=41.67, second payment has no VAT so net falls back to amount=50.00
if summary.TotalNetAmount != 91.67 {
t.Errorf("phase 4: expected TotalNetAmount 91.67 (41.67 + 50.00), got %.2f", summary.TotalNetAmount)
}
if summary.RemainingAmount != 0.00 {
t.Errorf("phase 4: expected RemainingAmount 0.00 (100.00 - 100.00), got %.2f", summary.RemainingAmount)
}
}
// TestVAT_DisableAndReEnable_Lifecycle verifies that VAT can be toggled off
// and back on, with payments correctly reflecting the current registration
// state at the time each payment is made.
func TestVAT_DisableAndReEnable_Lifecycle(t *testing.T) {
// Non-parallel: 47 VAT tests all UPDATE the shared business_settings row.
// With t.Parallel() + PostgreSQL row locks, tests block each other and
// this test's multiple state transitions (enable→disable→re-enable) are
// particularly susceptible to timeout.
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to enable VAT: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
_, _ = tx.Exec(ctx, "UPDATE bookings SET total_amount = 200.00 WHERE id = $1", bookingID)
// Phase 1: VAT enabled — pay £50 cash with VAT
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("phase 1: expected 200, got %d: %s", w.Code, w.Body.String())
}
var p1VAT sql.NullFloat64
var p1Net sql.NullFloat64
var p1VATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'cash' AND amount = 50.00`, bookingID).Scan(&p1VATApplicable, &p1VAT, &p1Net)
if err != nil {
t.Fatalf("phase 1: failed to query payment: %v", err)
}
if !p1VATApplicable {
t.Error("phase 1: expected VAT applicable when enabled")
}
if !p1VAT.Valid {
t.Fatal("phase 1: expected vat_amount to be set")
}
if p1VAT.Float64 != 8.33 {
t.Errorf("phase 1: expected vat_amount 8.33, got %.2f", p1VAT.Float64)
}
if !p1Net.Valid {
t.Fatal("phase 1: expected net_amount to be set")
}
if p1Net.Float64 != 41.67 {
t.Errorf("phase 1: expected net_amount 41.67, got %.2f", p1Net.Float64)
}
// Phase 2: Disable VAT
_, err = tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = FALSE`)
if err != nil {
t.Fatalf("phase 2: failed to disable VAT: %v", err)
}
// Pay another £50 cash — should have NO VAT
reqBody2, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "cash",
})
req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody2))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(ctx)
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r2.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("phase 2: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var phase2Resp CheckoutResponse
if err := json.Unmarshal(w2.Body.Bytes(), &phase2Resp); err != nil {
t.Fatalf("phase 2: failed to parse response: %v", err)
}
// Get the specific payment by its ID from the response (avoiding
// non-deterministic ORDER BY created_at DESC when NOW() returns the same
// transaction start time for all inserts within a transaction).
var p2VAT sql.NullFloat64
var p2Net sql.NullFloat64
var p2VATApplicable bool
var p2Amount float64
err = tx.QueryRow(ctx, `SELECT amount, is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, phase2Resp.CheckoutID).Scan(&p2Amount, &p2VATApplicable, &p2VAT, &p2Net)
if err != nil {
t.Fatalf("phase 2: failed to query payment: %v", err)
}
if p2VATApplicable {
t.Error("phase 2: expected NO VAT after disabling")
}
if p2VAT.Valid {
t.Errorf("phase 2: expected vat_amount NULL, got %.2f", p2VAT.Float64)
}
// Phase 3: Re-enable VAT
_, err = tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE`)
if err != nil {
t.Fatalf("phase 3: failed to re-enable VAT: %v", err)
}
// Pay another £50 cash — should have VAT again
reqBody3, _ := json.Marshal(map[string]interface{}{
"amount": 5000,
"payment_type": "full",
"payment_method": "cash",
})
req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3))
req3.Header.Set("Authorization", "Bearer "+token)
req3.Header.Set("Content-Type", "application/json")
req3 = req3.WithContext(ctx)
w3 := httptest.NewRecorder()
r3 := chi.NewRouter()
r3.Use(mw.RequireAuth)
r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r3.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("phase 3: expected 200, got %d: %s", w3.Code, w3.Body.String())
}
var phase3Resp CheckoutResponse
if err := json.Unmarshal(w3.Body.Bytes(), &phase3Resp); err != nil {
t.Fatalf("phase 3: failed to parse response: %v", err)
}
// Get the specific payment by its ID from the response (avoiding
// non-deterministic ORDER BY created_at DESC when NOW() returns the same
// transaction start time for all inserts within a transaction).
var p3VAT sql.NullFloat64
var p3Net sql.NullFloat64
var p3VATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, phase3Resp.CheckoutID).Scan(&p3VATApplicable, &p3VAT, &p3Net)
if err != nil {
t.Fatalf("phase 3: failed to query payment: %v", err)
}
if !p3VATApplicable {
t.Error("phase 3: expected VAT applicable after re-enabling")
}
if !p3VAT.Valid {
t.Fatal("phase 3: expected vat_amount to be set after re-enabling")
}
if p3VAT.Float64 != 8.33 {
t.Errorf("phase 3: expected vat_amount 8.33, got %.2f", p3VAT.Float64)
}
if !p3Net.Valid {
t.Fatal("phase 3: expected net_amount to be set after re-enabling")
}
if p3Net.Float64 != 41.67 {
t.Errorf("phase 3: expected net_amount 41.67, got %.2f", p3Net.Float64)
}
// Phase 4: Verify PaymentSummary across all three phases
svc := NewPaymentService()
summary, err := svc.GetBookingPaymentSummary(ctx, bookingID)
if err != nil {
t.Fatalf("phase 4: GetBookingPaymentSummary failed: %v", err)
}
if summary.PaidAmount != 150.00 {
t.Errorf("phase 4: expected PaidAmount 150.00 (50×3), got %.2f", summary.PaidAmount)
}
// Phase 1: 8.33 VAT, Phase 2: 0 VAT, Phase 3: 8.33 VAT
if summary.TotalVATAmount != 16.66 {
t.Errorf("phase 4: expected TotalVATAmount 16.66 (8.33 + 0 + 8.33), got %.2f", summary.TotalVATAmount)
}
// Phase 1: 41.67 net, Phase 2: 50.00 (no VAT fallback), Phase 3: 41.67 net
if summary.TotalNetAmount != 133.34 {
t.Errorf("phase 4: expected TotalNetAmount 133.34 (41.67 + 50.00 + 41.67), got %.2f", summary.TotalNetAmount)
}
if summary.RemainingAmount != 50.00 {
t.Errorf("phase 4: expected RemainingAmount 50.00 (200.00 - 150.00), got %.2f", summary.RemainingAmount)
}
}
// ─── VAT + discount + remaining balance ────────────────────────────────────
// TestVAT_DiscountAndCashPayment_RemainingBalance verifies that when a booking
// has a cash payment (with VAT) and a discount payment, the PaymentSummary
// correctly reports TotalVATAmount (only the cash portion), TotalNetAmount,
// PaidAmount, and RemainingAmount (total - paid).
// ─── T7: VAT Rounding Consistency Test ─────────────────────────────────────
// TestVAT_RoundingConsistency verifies that for edge case amounts,
// vat_amount + net_amount = gross_amount exactly (no rounding drift).
func TestVAT_RoundingConsistency(t *testing.T) {
edgeCases := []struct {
name string
amount float64
}{
{"£10.005 rounding boundary", 10.005},
{"£100.00 exact", 100.00},
{"£33.33 repeating decimal", 33.33},
{"£0.01 minimum", 0.01},
{"£9.99 just under £10", 9.99},
{"£19.99 just under £20", 19.99},
{"£99.99 just under £100", 99.99},
{"£1.00 single unit", 1.00},
{"£7.50 half", 7.50},
{"£66.66 repeating", 66.66},
{"£199.99 large", 199.99},
{"£0.50 half pound", 0.50},
{"£0.49 just under half", 0.49},
{"£0.51 just over half", 0.51},
{"£999.99 near thousand", 999.99},
}
for _, tt := range edgeCases {
t.Run(tt.name, func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
svc := NewPaymentService()
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
defer fixtures.DeleteBooking(tx, bookingID)
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
// Create a payment directly
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_by, created_at, updated_at)
VALUES ($1, 'full', 'cash', 'completed', $2, $3, NOW(), NOW())
RETURNING id
`, bookingID, tt.amount, adminID).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to insert payment: %v", err)
}
// Read the actual stored gross amount (DB may round to 2dp)
var storedGross float64
err = tx.QueryRow(ctx, "SELECT amount FROM payments WHERE id = $1", paymentID).Scan(&storedGross)
if err != nil {
t.Fatalf("failed to read stored amount: %v", err)
}
// Apply VAT
ApplyVATToBookingPayment(ctx, tx, paymentID)
// Verify vat + net = gross
var vatAmount sql.NullFloat64
var netAmount sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, paymentID).Scan(&isVATApplicable, &vatAmount, &netAmount)
if err != nil {
t.Fatalf("failed to query payment VAT fields: %v", err)
}
if !isVATApplicable {
t.Fatal("expected VAT to be applicable")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount to be set")
}
if !netAmount.Valid {
t.Fatal("expected net_amount to be set")
}
sum := vatAmount.Float64 + netAmount.Float64
diff := sum - storedGross
if diff < -0.02 || diff > 0.02 {
t.Errorf("vat(%.2f) + net(%.2f) = %.2f, expected stored gross %.2f (diff=%.4f)",
vatAmount.Float64, netAmount.Float64, sum, storedGross, diff)
} else if diff != 0 {
t.Logf("minor rounding drift: vat(%.2f) + net(%.2f) = %.2f, stored gross %.2f (diff=%.4f)",
vatAmount.Float64, netAmount.Float64, sum, storedGross, diff)
}
// Verify the payment summary aggregates also hold (with tolerance)
summary, sErr := svc.GetBookingPaymentSummary(ctx, bookingID)
if sErr != nil {
t.Fatalf("GetBookingPaymentSummary failed: %v", sErr)
}
summarySum := summary.TotalVATAmount + summary.TotalNetAmount
summaryDiff := summarySum - summary.PaidAmount
if summaryDiff < -0.02 || summaryDiff > 0.02 {
t.Errorf("summary: TotalVATAmount(%.2f) + TotalNetAmount(%.2f) = %.2f, expected PaidAmount %.2f (diff=%.4f)",
summary.TotalVATAmount, summary.TotalNetAmount, summarySum, summary.PaidAmount, summaryDiff)
}
})
}
}
// ─── T8: SPV vs MPV Lifecycle Tests ─────────────────────────────────────────
// NOTE: SPV and MPV lifecycle tests already exist above:
// - TestSPV_FullLifecycle_BuyAndRedeem (line ~1334)
// - TestMPV_FullLifecycle_BuyAndRedeem (line ~1446)
// - TestVoucherToggle_SPVPurchase_MPVRedeem (line ~2059)
// - TestVoucherToggle_MPVPurchase_SPVRedeem (line ~2220)
// These comprehensively cover the full lifecycle of both voucher types
// including purchase, redemption, and toggle scenarios.
func TestVAT_DiscountAndCashPayment_RemainingBalance(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
_, _ = tx.Exec(ctx, "UPDATE bookings SET total_amount = 100.00 WHERE id = $1", bookingID)
// Pay £30 cash — VAT applied via handler
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 3000,
"payment_type": "full",
"payment_method": "cash",
})
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("cash payment: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify cash payment has VAT
var vatAmount sql.NullFloat64
var cashNet sql.NullFloat64
var isVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&isVATApplicable, &vatAmount, &cashNet)
if err != nil {
t.Fatalf("failed to query cash payment: %v", err)
}
if !isVATApplicable {
t.Error("expected VAT applicable on cash payment")
}
if !vatAmount.Valid {
t.Fatal("expected vat_amount on cash payment")
}
if vatAmount.Float64 != 5.00 {
t.Errorf("expected vat_amount 5.00 (£30 at 20%%), got %.2f", vatAmount.Float64)
}
if !cashNet.Valid {
t.Fatal("expected net_amount on cash payment")
}
if cashNet.Float64 != 25.00 {
t.Errorf("expected net_amount 25.00 (£30/1.2), got %.2f", cashNet.Float64)
}
// Add a discount payment directly via the service layer
discountRecord := PaymentRecord{
BookingID: bookingID,
PaymentType: "full",
PaymentMethod: "discount",
Status: "completed",
Amount: 10.00,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &adminID,
}
svc := NewPaymentService()
discountID, err := svc.CreatePaymentRecord(ctx, discountRecord, nil)
if err != nil {
t.Fatalf("failed to create discount payment: %v", err)
}
// Apply VAT to discount payment — should be a no-op
ApplyVATToBookingPayment(ctx, tx, discountID)
// Verify discount payment has NO VAT
var discVAT sql.NullFloat64
var discNet sql.NullFloat64
var discVATApplicable bool
err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, discountID).Scan(&discVATApplicable, &discVAT, &discNet)
if err != nil {
t.Fatalf("failed to query discount payment: %v", err)
}
if discVATApplicable {
t.Error("expected discount payment to have is_vat_applicable FALSE")
}
if discVAT.Valid {
t.Errorf("expected vat_amount NULL for discount, got %.2f", discVAT.Float64)
}
// Verify PaymentSummary correctness
summary, err := svc.GetBookingPaymentSummary(ctx, bookingID)
if err != nil {
t.Fatalf("GetBookingPaymentSummary failed: %v", err)
}
if summary.TotalAmount != 100.00 {
t.Errorf("expected TotalAmount 100.00, got %.2f", summary.TotalAmount)
}
// PaidAmount = £30 cash + £10 discount
if summary.PaidAmount != 40.00 {
t.Errorf("expected PaidAmount 40.00 (30 cash + 10 discount), got %.2f", summary.PaidAmount)
}
// TotalVATAmount only from cash payment: £30 at 20% → vat=5.00
if summary.TotalVATAmount != 5.00 {
t.Errorf("expected TotalVATAmount 5.00 (from cash payment only), got %.2f", summary.TotalVATAmount)
}
// TotalNetAmount: cash net=25.00 + discount net falls back to amount=10.00
if summary.TotalNetAmount != 35.00 {
t.Errorf("expected TotalNetAmount 35.00 (25.00 cash net + 10.00 discount), got %.2f", summary.TotalNetAmount)
}
// RemainingAmount = total - paid = 100.00 - 40.00 = 60.00
if summary.RemainingAmount != 60.00 {
t.Errorf("expected RemainingAmount 60.00 (100.00 - 40.00), got %.2f", summary.RemainingAmount)
}
}