feat(payments): integrate VAT into gift cards, payment handlers, and till sales

Track voucher_type_at_purchase on gift card creation. Apply VAT at gift card purchase (SPV), at redemption (MPV). Use transaction-aware GetCardByIDQuerier for till saved card lookups. Apply VAT to all booking payments (split records, terminal, tip, checkout). Add TotalVATAmount and TotalNetAmount to PaymentSummary responses.

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-22 17:05:39 +01:00
co-authored by Sisyphus
parent 7cd71e4485
commit 6561ceb7a9
4 changed files with 181 additions and 36 deletions
+25 -6
View File
@@ -71,6 +71,8 @@ type PaymentSummary struct {
PaidAmount float64
RefundedAmount float64
RemainingAmount float64
TotalVATAmount float64
TotalNetAmount float64
Payments []PaymentRecord
Refunds []RefundRecord
}
@@ -201,7 +203,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
}
defer rows.Close()
var paidAmount float64
var paidAmount, totalVATAmount, totalNetAmount float64
for rows.Next() {
var p PaymentRecord
err := rows.Scan(
@@ -216,9 +218,22 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
summary.Payments = append(summary.Payments, p)
if p.Status == "completed" {
paidAmount += p.Amount
if p.VATAmount != nil {
totalVATAmount += *p.VATAmount
}
if p.NetAmount != nil {
totalNetAmount += *p.NetAmount
} else if p.VATAmount == nil {
// Only fallback to gross amount if no VAT was applied at all.
// When VAT is present, net_amount is always set by apply_vat_to_payment,
// so this fallback only applies to non-VAT payments where net == gross.
totalNetAmount += p.Amount
}
}
}
summary.PaidAmount = paidAmount
summary.TotalVATAmount = totalVATAmount
summary.TotalNetAmount = totalNetAmount
refundRows, err := db.Conn.Query(ctx, `
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
@@ -371,12 +386,9 @@ func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID st
var info BookingPaymentInfo
err := db.Conn.QueryRow(ctx, `
SELECT b.start_time, b.status,
COALESCE(bt.total_amount, 0),
COALESCE(b.total_amount, 0),
COALESCE(pt.total_paid, 0)
FROM bookings b
LEFT JOIN (
SELECT id, total_amount FROM bookings WHERE id = $1
) bt ON b.id = bt.id
LEFT JOIN (
SELECT booking_id, SUM(amount) AS total_paid
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
@@ -526,8 +538,15 @@ func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCard
}
func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) (*SavedCard, error) {
return s.GetCardByIDQuerier(ctx, db.Conn, cardID, userID)
}
// GetCardByIDQuerier is identical to GetCardByID but accepts a db.Querier
// so the lookup can be performed inside a transaction. Callers inside an
// existing transaction should pass their tx variable instead of db.Conn.
func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, cardID, userID string) (*SavedCard, error) {
var c SavedCard
err := db.Conn.QueryRow(ctx, `
err := q.QueryRow(ctx, `
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
FROM user_saved_cards
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL