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