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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user