//go:build test && dev package payments import ( "bytes" "context" "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" "github.com/stretchr/testify/assert" ) 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) } } // TestMPV_TreatedAsSPV_AppliesVATAtTillSale verifies that a till sale under // voucher_type=MPV still gets VAT: per HMRC VAT Notice 700/7 a salon-only gift // card is an SPV by definition, so MPV is overridden to SPV for VAT // application (M6). func TestMPV_TreatedAsSPV_AppliesVATAtTillSale(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 TRUE for MPV (treated as SPV) till sale") } if !vatAmount.Valid || vatAmount.Float64 != 8.33 { t.Errorf("expected vat_amount 8.33 for MPV (treated as SPV) till sale, got %v", vatAmount) } if !netAmount.Valid || netAmount.Float64 != 41.67 { t.Errorf("expected net_amount 41.67 for MPV (treated as SPV) till sale, got %v", netAmount) } } 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, ®Number) 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 != "SPV" { t.Errorf("expected VoucherType SPV (MPV is overridden to SPV for VAT application), 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 TestEffectiveSPV_MPVConfig_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) — configured MPV is treated // as SPV for VAT application (HMRC VAT Notice 700/7), so VAT IS applied at // sale (and the card is stored with the effective voucher type 'SPV') 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 (MPV treated as SPV) 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 (treated as SPV): expected till_sale VAT") } if !tsVAT.Valid || tsVAT.Float64 != 16.67 { t.Errorf("MPV (treated as SPV): expected vat_amount 16.67 at sale, got %v", tsVAT) } // 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 — the card is stored // with the effective voucher type 'SPV', so VAT was already charged at // purchase; NO 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()) } // Verify NO VAT is applied at redemption — the card was stored as SPV // (effective type), so VAT was already charged at purchase 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("SPV redemption: expected NO VAT at redemption (VAT already charged at purchase)") } if payVAT.Valid { t.Errorf("SPV redemption: expected NULL vat_amount, got %.2f", payVAT.Float64) } if payNet.Valid { t.Errorf("SPV redemption: expected NULL net_amount, 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.GetBookingRemainingBalancePence(ctx, bookingID) if rErr != nil { t.Fatalf("GetBookingRemainingBalancePence failed: %v", rErr) } if remaining != 7000 { t.Errorf("expected remaining 7000 pence (£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.GetBookingRemainingBalancePence(ctx, bookingID) if rErr2 != nil { t.Fatalf("GetBookingRemainingBalancePence failed: %v", rErr2) } if remaining2 != 3000 { t.Errorf("expected remaining 3000 pence (£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.GetBookingRemainingBalancePence(ctx, bookingID) if rErr3 != nil { t.Fatalf("GetBookingRemainingBalancePence 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_AppliesVAT verifies a gift card topup with voucher_type=MPV // still applies VAT (MPV is overridden to SPV for VAT application, M6). func TestMPV_Topup_AppliesVAT(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 treated as SPV, so VAT applies 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 VAT IS applied on MPV (treated as SPV) 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 VAT on MPV (treated as SPV) topup") } if !vatAmount.Valid || vatAmount.Float64 != 4.17 { t.Errorf("expected vat_amount 4.17 on MPV (treated as SPV) topup, got %v", vatAmount) } // 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 applies VAT even when // voucher_type is MPV (MPV is overridden to SPV for VAT application, M6). 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 TRUE for MPV (treated as SPV)") } if !vatAmount.Valid || vatAmount.Float64 != 8.33 { t.Errorf("expected vat_amount 8.33 for MPV (treated as SPV), got %v", vatAmount) } } // ─── Exhaustive voucher_type toggle + legacy tests ───────────────────────── // TestVoucherToggle_MPVPurchase_SingleVATAtSale verifies that a gift card // bought under voucher_type=MPV is recorded with the effective voucher type // 'SPV' (HMRC VAT Notice 700/7 — MPV is unavailable for a salon-only gift-card // business) and gets VAT at sale only. Because the card is stored as SPV, // redeeming it later — even after toggling business_settings back to SPV — // applies NO VAT at redemption. func TestVoucherToggle_MPVPurchase_SingleVATAtSale(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 under MPV — MPV treated as 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) 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 (treated as SPV): expected VAT at sale") } if !tsVAT.Valid || tsVAT.Float64 != 16.67 { t.Errorf("MPV purchase (treated as SPV): expected vat_amount 16.67 at sale, got %v", tsVAT) } 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 != "SPV" { t.Errorf("expected voucher_type_at_purchase 'SPV' (MPV overridden to the effective SPV), got %v", storedVTP) } // Phase 2: Toggle to SPV — the card already stores the effective SPV type, // so redemption is unaffected by the toggle _, 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 — the card is stored as SPV, so VAT was already charged // at purchase and NO 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 NO VAT at redemption (card stored as SPV; VAT charged at purchase)") } if payVAT.Valid { t.Errorf("expected NULL vat_amount at SPV-stored redemption, 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 != "SPV" { t.Errorf("expected VoucherType SPV (MPV is overridden to SPV for VAT application), 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) // - TestEffectiveSPV_MPVConfig_FullLifecycle_BuyAndRedeem (line ~1446) // - TestVoucherToggle_SPVPurchase_MPVRedeem (line ~2059) // - TestVoucherToggle_MPVPurchase_SingleVATAtSale (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) } } func TestGetVATConfig_QueryError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() cfg, err := GetVATConfig(ctx, db.Conn) assert.Error(t, err) assert.Nil(t, cfg) }