diff --git a/backend/handlers/payments/vat.go b/backend/handlers/payments/vat.go new file mode 100644 index 0000000..8c71acc --- /dev/null +++ b/backend/handlers/payments/vat.go @@ -0,0 +1,78 @@ +package payments + +import ( + "context" + "log" + + "crussell/db" +) + +type VATConfig struct { + IsVATRegistered bool + DefaultVATRate float64 + VoucherType string +} + +// GetVATConfig reads VAT config using the given querier, which can be either +// a *db.PoolProxy (for non-transactional reads), a pgx.Tx (to read inside a +// transaction), or any other type that implements db.Querier. +func GetVATConfig(ctx context.Context, q db.Querier) (*VATConfig, error) { + var cfg VATConfig + err := q.QueryRow(ctx, ` + SELECT COALESCE(is_vat_registered, FALSE), + COALESCE(default_vat_rate, 20.0), + COALESCE(voucher_type, 'SPV') + FROM business_settings + LIMIT 1 + `).Scan(&cfg.IsVATRegistered, &cfg.DefaultVATRate, &cfg.VoucherType) + if err != nil { + return nil, err + } + return &cfg, nil +} + +// ApplyVATToBookingPayment reads VAT config and calls apply_vat_to_payment +// on a booking payment record. The q parameter is used for both reading the +// VAT config and for the defensive payment-method check, ensuring all reads +// are consistent with the caller's transactional context. Errors are logged +// — VAT failure should not block the payment flow. +func ApplyVATToBookingPayment(ctx context.Context, q db.Querier, paymentID string) { + vatCfg, err := GetVATConfig(ctx, q) + if err != nil { + log.Printf("Failed to read VAT config for payment %s: %v", paymentID, err) + return + } + if !vatCfg.IsVATRegistered { + return + } + // Discount and on_the_house payments must never have VAT applied. + // Read the payment method inside the same transactional context so that + // the just-inserted row is visible (defence against READ COMMITTED + // isolation when q is a pgx.Tx). + var method string + if qErr := q.QueryRow(ctx, "SELECT payment_method FROM payments WHERE id = $1", paymentID).Scan(&method); qErr == nil && (method == "discount" || method == "on_the_house") { + return + } + if _, execErr := q.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", paymentID, vatCfg.DefaultVATRate); execErr != nil { + log.Printf("Failed to apply VAT to payment %s: %v", paymentID, execErr) + } +} + +// ApplyVATToTillSale reads VAT config and calls apply_vat_to_till_sale for +// SPV gift card till sales. Errors are logged — VAT failure should not +// block the sale flow. +func ApplyVATToTillSale(ctx context.Context, q db.Querier, saleID string) { + vatCfg, err := GetVATConfig(ctx, q) + if err != nil { + log.Printf("Failed to read VAT config for till sale %s: %v", saleID, err) + return + } + if !vatCfg.IsVATRegistered || vatCfg.VoucherType != "SPV" { + return + } + if _, execErr := q.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", saleID, vatCfg.DefaultVATRate); execErr != nil { + log.Printf("Failed to apply VAT to till sale %s: %v", saleID, execErr) + } +} + + diff --git a/backend/handlers/payments/vat_test.go b/backend/handlers/payments/vat_test.go new file mode 100644 index 0000000..6b0899f --- /dev/null +++ b/backend/handlers/payments/vat_test.go @@ -0,0 +1,3401 @@ +//go:build test && dev +// +build test,dev + +package payments + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crussell/db" + "crussell/mw" + "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/go-chi/chi/v5" +) + +func TestSPV_VATAppliedAtTillSale(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + adminToken := jwt.GenerateTestToken(adminID, "admin") + + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "cash", + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp TillSaleResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + var vatAmount sql.NullFloat64 + var netAmount sql.NullFloat64 + var isVATApplicable bool + err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&isVATApplicable, &vatAmount, &netAmount) + if err != nil { + t.Fatalf("failed to query till_sales: %v", err) + } + + if !isVATApplicable { + t.Error("expected is_vat_applicable to be TRUE for SPV till sale") + } + if !vatAmount.Valid { + t.Fatal("expected vat_amount to be set for SPV till sale") + } + if vatAmount.Float64 != 8.33 { + t.Errorf("expected vat_amount 8.33, got %.2f", vatAmount.Float64) + } + if !netAmount.Valid { + t.Fatal("expected net_amount to be set for SPV till sale") + } + if netAmount.Float64 != 41.67 { + t.Errorf("expected net_amount 41.67, got %.2f", netAmount.Float64) + } +} + +func TestMPV_NoVATAtTillSale(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + adminToken := jwt.GenerateTestToken(adminID, "admin") + + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "cash", + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp TillSaleResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + var vatAmount sql.NullFloat64 + var netAmount sql.NullFloat64 + var isVATApplicable bool + err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, resp.ID).Scan(&isVATApplicable, &vatAmount, &netAmount) + if err != nil { + t.Fatalf("failed to query till_sales: %v", err) + } + + if isVATApplicable { + t.Error("expected is_vat_applicable to be FALSE for MPV till sale") + } + if vatAmount.Valid { + t.Errorf("expected vat_amount to be NULL for MPV till sale, got %.2f", vatAmount.Float64) + } + if netAmount.Valid { + t.Errorf("expected net_amount to be NULL for MPV till sale, got %.2f", netAmount.Float64) + } +} + +func TestMPV_VATAppliedAtRedemption(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 5.00, voucher_type = 'MPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + cfg, err := GetVATConfig(ctx, db.Conn) + if err != nil { + t.Fatalf("GetVATConfig failed: %v", err) + } + + if !cfg.IsVATRegistered { + t.Error("expected IsVATRegistered to be TRUE") + } + if cfg.DefaultVATRate != 5.00 { + t.Errorf("expected DefaultVATRate 5.00, got %.2f", cfg.DefaultVATRate) + } + if cfg.VoucherType != "MPV" { + t.Errorf("expected VoucherType MPV, got %s", cfg.VoucherType) + } + + // Test with FALSE + _, _ = tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = FALSE`) + cfg2, err := GetVATConfig(ctx, db.Conn) + if err != nil { + t.Fatalf("GetVATConfig failed: %v", err) + } + if cfg2.IsVATRegistered { + t.Error("expected IsVATRegistered to be FALSE after update") + } +} + +func TestApplyVATToBookingPayment_Direct(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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 phase3VAT sql.NullFloat64 + var phase3Net sql.NullFloat64 + var phase3VATApplicable bool + var phase3Amount float64 + // Get the latest cash payment (the second one) + err = tx.QueryRow(ctx, `SELECT amount, is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'cash' ORDER BY created_at DESC LIMIT 1`, bookingID).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) { + t.Parallel() + 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) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + token := jwt.GenerateTestToken(adminID, "admin") + + // Phase 1: Buy gift card via till sale (cash) — SPV, so VAT at sale + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 100.00, + PaymentMethod: "cash", + } + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("till sale: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var tsResp TillSaleResponse + json.NewDecoder(w.Body).Decode(&tsResp) + + // Verify till_sale has VAT applied + var tsVAT sql.NullFloat64 + var tsNet sql.NullFloat64 + var tsVATApplicable bool + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT, &tsNet) + if !tsVATApplicable { + t.Error("SPV: expected till_sale VAT applicable") + } + if !tsVAT.Valid || tsVAT.Float64 != 16.67 { + t.Errorf("SPV: expected till_sale vat 16.67, got %.2f", tsVAT.Float64) + } + + // Get the gift card ID from the till sale + var cardID string + err = tx.QueryRow(ctx, "SELECT item_id FROM till_sales WHERE id = $1", tsResp.ID).Scan(&cardID) + if err != nil { + t.Fatalf("failed to get gift card ID: %v", err) + } + + // Phase 2: Create a booking and redeem the gift card + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) + + // Redeem £50 via gift card + redeemBody, _ := json.Marshal(map[string]interface{}{ + "amount": 5000, + "payment_type": "full", + "payment_method": "giftcard", + "gift_card_id": cardID, + }) + req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody)) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + req2 = req2.WithContext(ctx) + + w2 := httptest.NewRecorder() + r2 := chi.NewRouter() + r2.Use(mw.RequireAuth) + r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) + r2.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + // Verify NO VAT on redemption (SPV — VAT already at sale) + var payVAT sql.NullFloat64 + var payVATApplicable bool + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT) + if payVATApplicable { + t.Error("SPV redemption: expected NO VAT at redemption") + } + if payVAT.Valid { + t.Errorf("SPV redemption: expected NULL vat, got %.2f", payVAT.Float64) + } + + // Verify gift card balance + var remaining float64 + tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining) + if remaining != 50.00 { + t.Errorf("expected remaining balance 50.00, got %.2f", remaining) + } +} + +func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + token := jwt.GenerateTestToken(adminID, "admin") + + // Phase 1: Buy gift card via till sale (cash) — MPV, so NO VAT at sale + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 100.00, + PaymentMethod: "cash", + } + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("till sale: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var tsResp TillSaleResponse + json.NewDecoder(w.Body).Decode(&tsResp) + + // Verify till_sale has NO VAT (MPV) + var tsVAT sql.NullFloat64 + var tsVATApplicable bool + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT) + if tsVATApplicable { + t.Error("MPV: expected NO till_sale VAT") + } + if tsVAT.Valid { + t.Errorf("MPV: expected NULL vat_amount at sale, got %.2f", tsVAT.Float64) + } + + // Get the gift card ID + var cardID string + err = tx.QueryRow(ctx, "SELECT item_id FROM till_sales WHERE id = $1", tsResp.ID).Scan(&cardID) + if err != nil { + t.Fatalf("failed to get gift card ID: %v", err) + } + + // Phase 2: Create a booking and redeem the gift card — MPV so VAT at redemption + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) + + redeemBody, _ := json.Marshal(map[string]interface{}{ + "amount": 5000, + "payment_type": "full", + "payment_method": "giftcard", + "gift_card_id": cardID, + }) + req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody)) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + req2 = req2.WithContext(ctx) + + w2 := httptest.NewRecorder() + r2 := chi.NewRouter() + r2.Use(mw.RequireAuth) + r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) + r2.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + // Verify VAT IS applied at redemption (MPV) + var payVAT sql.NullFloat64 + var payNet sql.NullFloat64 + var payVATApplicable bool + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT, &payNet) + if !payVATApplicable { + t.Error("MPV redemption: expected VAT at redemption") + } + if !payVAT.Valid { + t.Fatal("MPV redemption: expected vat_amount to be set") + } + if payVAT.Float64 != 8.33 { + t.Errorf("MPV redemption: expected vat 8.33, got %.2f", payVAT.Float64) + } + if !payNet.Valid { + t.Fatal("MPV redemption: expected net_amount to be set") + } + if payNet.Float64 != 41.67 { + t.Errorf("MPV redemption: expected net 41.67, got %.2f", payNet.Float64) + } +} + +func TestVAT_TopupGiftCard_NoVATOnTopup(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + token := jwt.GenerateTestToken(adminID, "admin") + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) + _, _ = tx.Exec(ctx, "UPDATE bookings SET total_amount = 100.00 WHERE id = $1", bookingID) + + // Pay £30 cash with VAT + reqBody, _ := json.Marshal(map[string]interface{}{ + "amount": 3000, + "payment_type": "full", + "payment_method": "cash", + }) + req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Remaining balance should be total - paid (gross) = 100 - 30 = 70 + svc := NewPaymentService() + remaining, rErr := svc.GetBookingRemainingBalanceCents(ctx, bookingID) + if rErr != nil { + t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr) + } + if remaining != 7000 { + t.Errorf("expected remaining 7000 cents (£70), got %d", remaining) + } + + // Pay another £40 with VAT — remaining should be 100 - 70 = 30 + reqBody2, _ := json.Marshal(map[string]interface{}{ + "amount": 4000, + "payment_type": "full", + "payment_method": "cash", + }) + req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody2)) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + req2 = req2.WithContext(ctx) + + w2 := httptest.NewRecorder() + r2 := chi.NewRouter() + r2.Use(mw.RequireAuth) + r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) + r2.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("second payment: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + remaining2, rErr2 := svc.GetBookingRemainingBalanceCents(ctx, bookingID) + if rErr2 != nil { + t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr2) + } + if remaining2 != 3000 { + t.Errorf("expected remaining 3000 cents (£30), got %d", remaining2) + } + + // Pay the final £30 — remaining should be 0 + reqBody3, _ := json.Marshal(map[string]interface{}{ + "amount": 3000, + "payment_type": "full", + "payment_method": "cash", + }) + req3 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(reqBody3)) + req3.Header.Set("Authorization", "Bearer "+token) + req3.Header.Set("Content-Type", "application/json") + req3 = req3.WithContext(ctx) + + w3 := httptest.NewRecorder() + r3 := chi.NewRouter() + r3.Use(mw.RequireAuth) + r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) + r3.ServeHTTP(w3, req3) + + if w3.Code != http.StatusOK { + t.Fatalf("third payment: expected 200, got %d: %s", w3.Code, w3.Body.String()) + } + + remaining3, rErr3 := svc.GetBookingRemainingBalanceCents(ctx, bookingID) + if rErr3 != nil { + t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr3) + } + if remaining3 != 0 { + t.Errorf("expected remaining 0, got %d", remaining3) + } +} + +func TestVAT_GiftCardCRUD_DoesNotInterfere(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + adminToken := jwt.GenerateTestToken(adminID, "admin") + + // Create two gift cards in DB + var card1ID, card2ID string + err = tx.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) + VALUES (100.00, 100.00, $1) RETURNING id + `, adminID).Scan(&card1ID) + if err != nil { + t.Fatalf("failed to create card 1: %v", err) + } + err = tx.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) + VALUES (50.00, 50.00, $1) RETURNING id + `, adminID).Scan(&card2ID) + if err != nil { + t.Fatalf("failed to create card 2: %v", err) + } + + // 1) Transfer between cards (card1→card2) — unaffected by VAT + transferBody, _ := json.Marshal(map[string]interface{}{ + "to_card_id": card2ID, + "amount": 30.00, + }) + req1 := httptest.NewRequest("POST", "/api/admin/gift-cards/"+card1ID+"/transfer", bytes.NewBuffer(transferBody)) + req1.Header.Set("Authorization", "Bearer "+adminToken) + req1.Header.Set("Content-Type", "application/json") + req1 = req1.WithContext(ctx) + w1 := httptest.NewRecorder() + r1 := chi.NewRouter() + r1.Use(mw.RequireAuth) + r1.Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard) + r1.ServeHTTP(w1, req1) + if w1.Code != http.StatusOK { + t.Fatalf("transfer: expected 200, got %d: %s", w1.Code, w1.Body.String()) + } + + // Verify card balances after transfer + var c1Bal, c2Bal float64 + tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", card1ID).Scan(&c1Bal) + tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", card2ID).Scan(&c2Bal) + if c1Bal != 70.00 || c2Bal != 80.00 { + t.Errorf("expected card1=70.00 card2=80.00, got card1=%.2f card2=%.2f", c1Bal, c2Bal) + } + + // 2) Get gift cards list — should work with VAT fields present on payments + getReq := httptest.NewRequest("GET", "/api/admin/giftcards?limit=10&offset=0", nil) + getReq.Header.Set("Authorization", "Bearer "+adminToken) + getReq = getReq.WithContext(ctx) + w2 := httptest.NewRecorder() + r2 := chi.NewRouter() + r2.Use(mw.RequireAuth) + r2.Get("/api/admin/giftcards", GetGiftCards) + r2.ServeHTTP(w2, getReq) + if w2.Code != http.StatusOK { + t.Fatalf("list: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + // 3) Get individual gift card — should work + getOne := httptest.NewRequest("GET", "/api/admin/giftcards/"+card1ID, nil) + getOne.Header.Set("Authorization", "Bearer "+adminToken) + getOne = getOne.WithContext(ctx) + w3 := httptest.NewRecorder() + r3 := chi.NewRouter() + r3.Use(mw.RequireAuth) + r3.Get("/api/admin/giftcards/{id}", GetGiftCards) + r3.ServeHTTP(w3, getOne) + if w3.Code != http.StatusOK { + t.Fatalf("get one: expected 200, got %d: %s", w3.Code, w3.Body.String()) + } +} + +// ─── Additional tests ────────────────────────────────────────────────────────── + +// TestMPV_Topup_NoVAT verifies a gift card topup with voucher_type=MPV does not +// apply VAT at sale. +func TestMPV_Topup_NoVAT(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + token := jwt.GenerateTestToken(adminID, "admin") + + // Create a gift card first + var cardID string + err = tx.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining) + VALUES (50.00, 50.00) RETURNING id + `).Scan(&cardID) + if err != nil { + t.Fatalf("failed to create gift card: %v", err) + } + + // Top up via till sale (cash) — MPV, so no VAT + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "topup", + GiftCardID: &cardID, + Amount: 25.00, + PaymentMethod: "cash", + } + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("topup: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var tsResp TillSaleResponse + json.NewDecoder(w.Body).Decode(&tsResp) + + // Verify NO VAT on MPV topup + var vatAmount sql.NullFloat64 + var isVATApplicable bool + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&isVATApplicable, &vatAmount) + if isVATApplicable { + t.Error("expected no VAT on MPV topup") + } + if vatAmount.Valid { + t.Errorf("expected NULL vat_amount on MPV topup, got %.2f", vatAmount.Float64) + } + + // Verify gift card balance still increased + var remaining float64 + tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", cardID).Scan(&remaining) + if remaining != 75.00 { + t.Errorf("expected remaining 75.00, got %.2f", remaining) + } +} + +// TestCreateBookingPayment_VATApplied verifies that the online Square payment +// path (CreateBookingPayment) applies VAT when the business is registered. +// Uses a booking that has already started so buildSplitRecords returns a +// single payment record, making VAT assertion straightforward. +func TestCreateBookingPayment_VATApplied(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + token := jwt.GenerateTestToken(adminID, "admin") + + // Phase 1: Buy gift card as SPV — VAT at sale + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 100.00, + PaymentMethod: "cash", + } + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("till sale: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var tsResp TillSaleResponse + json.NewDecoder(w.Body).Decode(&tsResp) + + // Verify VAT applied at sale (SPV) and voucher_type_at_purchase stored + var tsVAT sql.NullFloat64 + var tsVATApplicable bool + var storedVTP sql.NullString + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT) + if !tsVATApplicable { + t.Error("SPV purchase: expected VAT at sale") + } + + var cardID string + err = tx.QueryRow(ctx, "SELECT item_id FROM till_sales WHERE id = $1", tsResp.ID).Scan(&cardID) + if err != nil { + t.Fatalf("failed to get gift card ID: %v", err) + } + err = tx.QueryRow(ctx, "SELECT voucher_type_at_purchase FROM gift_cards WHERE id = $1", cardID).Scan(&storedVTP) + if err != nil { + t.Fatalf("failed to get voucher_type_at_purchase from gift card: %v", err) + } + if !storedVTP.Valid || storedVTP.String != "SPV" { + t.Errorf("expected voucher_type_at_purchase 'SPV', got %v", storedVTP) + } + + // Phase 2: Toggle voucher_type to MPV — should NOT affect already-purchased cards + _, err = tx.Exec(ctx, `UPDATE business_settings SET voucher_type = 'MPV'`) + if err != nil { + t.Fatalf("failed to toggle to MPV: %v", err) + } + + // Phase 3: Redeem — stored voucher_type is SPV, so NO VAT at redemption + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) + + redeemBody, _ := json.Marshal(map[string]interface{}{ + "amount": 5000, + "payment_type": "full", + "payment_method": "giftcard", + "gift_card_id": cardID, + }) + req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody)) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + req2 = req2.WithContext(ctx) + + w2 := httptest.NewRecorder() + r2 := chi.NewRouter() + r2.Use(mw.RequireAuth) + r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) + r2.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + var payVAT sql.NullFloat64 + var payVATApplicable bool + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT) + if payVATApplicable { + t.Error("expected NO VAT at redemption (card stored SPV, VAT already paid at sale)") + } + if payVAT.Valid { + t.Errorf("expected NULL vat at SPV-stored redemption, got %.2f", payVAT.Float64) + } +} + +// TestApplyVATToTillSale_MPV verifies ApplyVATToTillSale has no effect when +// voucher_type is MPV. +func TestApplyVATToTillSale_MPV(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + + var saleID string + err = tx.QueryRow(ctx, ` + INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at) + VALUES ('gift_card', NULL, 'test', 1, 50.00, 50.00, 'cash', 'completed', $1, NOW(), NOW()) + RETURNING id + `, adminID).Scan(&saleID) + if err != nil { + t.Fatalf("failed to insert till_sale: %v", err) + } + + ApplyVATToTillSale(ctx, db.Conn, saleID) + + var vatAmount sql.NullFloat64 + var isVATApplicable bool + err = tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, saleID).Scan(&isVATApplicable, &vatAmount) + if err != nil { + t.Fatalf("failed to query till_sale: %v", err) + } + + if isVATApplicable { + t.Error("expected is_vat_applicable to be FALSE for MPV") + } + if vatAmount.Valid { + t.Errorf("expected vat_amount to be NULL for MPV, got %.2f", vatAmount.Float64) + } +} + +// ─── Exhaustive voucher_type toggle + legacy tests ───────────────────────── + +// TestVoucherToggle_MPVPurchase_SPVRedeem verifies that a gift card bought +// as MPV (no VAT at sale, deferred to redemption) and then redeemed after +// switching to SPV still gets VAT at redemption — because the stored +// voucher_type_at_purchase is MPV, overriding the current business_settings. +func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) + if err != nil { + t.Fatalf("failed to update business_settings: %v", err) + } + + adminID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + token := jwt.GenerateTestToken(adminID, "admin") + + // Phase 1: Buy gift card as MPV — no VAT at sale + reqBody := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 100.00, + PaymentMethod: "cash", + } + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/admin/till/sale", CreateTillSale) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("till sale: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var tsResp TillSaleResponse + json.NewDecoder(w.Body).Decode(&tsResp) + + var tsVAT sql.NullFloat64 + var tsVATApplicable bool + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT) + if tsVATApplicable { + t.Error("MPV purchase: expected NO VAT at sale") + } + + var cardID string + var storedVTP sql.NullString + err = tx.QueryRow(ctx, "SELECT item_id FROM till_sales WHERE id = $1", tsResp.ID).Scan(&cardID) + if err != nil { + t.Fatalf("failed to get card ID: %v", err) + } + tx.QueryRow(ctx, "SELECT voucher_type_at_purchase FROM gift_cards WHERE id = $1", cardID).Scan(&storedVTP) + if !storedVTP.Valid || storedVTP.String != "MPV" { + t.Errorf("expected voucher_type_at_purchase 'MPV', got %v", storedVTP) + } + + // Phase 2: Toggle to SPV — should NOT affect already-purchased cards + _, err = tx.Exec(ctx, `UPDATE business_settings SET voucher_type = 'SPV'`) + if err != nil { + t.Fatalf("failed to toggle to SPV: %v", err) + } + + // Phase 3: Redeem — stored voucher_type is MPV, so VAT IS applied at redemption + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBooking(tx, adminID, serviceID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + _, _ = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) + + redeemBody, _ := json.Marshal(map[string]interface{}{ + "amount": 5000, + "payment_type": "full", + "payment_method": "giftcard", + "gift_card_id": cardID, + }) + req2 := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/payment", bytes.NewBuffer(redeemBody)) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + req2 = req2.WithContext(ctx) + + w2 := httptest.NewRecorder() + r2 := chi.NewRouter() + r2.Use(mw.RequireAuth) + r2.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) + r2.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + var payVAT sql.NullFloat64 + var payVATApplicable bool + tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT) + if !payVATApplicable { + t.Error("expected VAT at redemption (card stored MPV)") + } + if !payVAT.Valid { + t.Fatal("expected vat_amount to be set at MPV-stored redemption") + } + if payVAT.Float64 != 8.33 { + t.Errorf("expected vat 8.33, got %.2f", payVAT.Float64) + } +} + +// TestLegacyGiftCard_NullVoucherType defaults NULL voucher_type_at_purchase +// to SPV behavior — no VAT at redemption. +func TestLegacyGiftCard_NullVoucherType(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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: time.Now(), + UpdatedAt: time.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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 5.00, voucher_type = 'MPV'`) + if err != nil { + t.Fatalf("failed to set VAT config: %v", err) + } + + // Read via explicit tx querier — must see uncommitted changes. + cfg, err := GetVATConfig(ctx, tx) + if err != nil { + t.Fatalf("GetVATConfig with tx querier failed: %v", err) + } + if !cfg.IsVATRegistered { + t.Error("expected IsVATRegistered to be TRUE (read via tx)") + } + if cfg.DefaultVATRate != 5.00 { + t.Errorf("expected DefaultVATRate 5.00, got %.2f", cfg.DefaultVATRate) + } + if cfg.VoucherType != "MPV" { + t.Errorf("expected VoucherType MPV, got %s", cfg.VoucherType) + } +} + +func TestGetVATConfig_DbConnQuerier(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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()) + } + + // Verify NO VAT on the second payment (the latest cash payment) + 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 booking_id = $1 AND payment_method = 'cash' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&amount3, &vatApplicable3, &vat3, &net3) + if err != nil { + t.Fatalf("phase 3: failed to query latest 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) { + t.Parallel() + 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()) + } + + // Get the latest cash payment (the second one, without VAT) + 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 booking_id = $1 AND payment_method = 'cash' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&p2Amount, &p2VATApplicable, &p2VAT, &p2Net) + if err != nil { + t.Fatalf("phase 2: failed to query latest 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()) + } + + // Get the latest cash payment (the third one, with VAT re-enabled) + 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 booking_id = $1 AND payment_method = 'cash' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&p3VATApplicable, &p3VAT, &p3Net) + if err != nil { + t.Fatalf("phase 3: failed to query latest 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). +func TestVAT_DiscountAndCashPayment_RemainingBalance(t *testing.T) { + t.Parallel() + 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: time.Now(), + UpdatedAt: time.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) + } +}