Add GetVATConfig for reading VAT settings via any db.Querier, ApplyVATToBookingPayment for applying VAT to payment records, and ApplyVATToTillSale for SPV gift card till sales. Errors are logged without blocking payment flow. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
3402 lines
114 KiB
Go
3402 lines
114 KiB
Go
//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)
|
||
}
|
||
}
|