refactor(payments): integrate VAT into gift card buy flow and wrap in transactions

Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-24 23:43:40 +01:00
co-authored by Sisyphus
parent 40bbd9ba49
commit 7b24f8e484
14 changed files with 1409 additions and 518 deletions
+137 -5
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
@@ -2544,8 +2545,8 @@ func TestVAT_DiscountPayment_NoVAT(t *testing.T) {
PaymentMethod: "discount",
Status: "completed",
Amount: 10.00,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &adminID,
}
svc := NewPaymentService()
@@ -3069,7 +3070,10 @@ func TestVAT_DisableVATRegistration_Lifecycle(t *testing.T) {
// and back on, with payments correctly reflecting the current registration
// state at the time each payment is made.
func TestVAT_DisableAndReEnable_Lifecycle(t *testing.T) {
t.Parallel()
// 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`)
@@ -3265,6 +3269,134 @@ func TestVAT_DisableAndReEnable_Lifecycle(t *testing.T) {
// 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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
t.Fatalf("failed to update business_settings: %v", err)
}
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
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 !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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
@@ -3345,8 +3477,8 @@ func TestVAT_DiscountAndCashPayment_RemainingBalance(t *testing.T) {
PaymentMethod: "discount",
Status: "completed",
Amount: 10.00,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &adminID,
}
svc := NewPaymentService()