From 6561ceb7a9353fc662d6507f3df82d99000210e9 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 17:05:39 +0100 Subject: [PATCH] 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 --- backend/handlers/payments/giftcards.go | 46 +++++++++++---- backend/handlers/payments/handlers.go | 79 +++++++++++++++++++++++++- backend/handlers/payments/service.go | 31 ++++++++-- backend/handlers/payments/till.go | 61 ++++++++++++++------ 4 files changed, 181 insertions(+), 36 deletions(-) diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index df729e9..9aea3d2 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -354,11 +354,16 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { var gc GiftCard var lastUsedAt sql.NullTime + var purchaseVoucherType string + _ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) + if purchaseVoucherType == "" { + purchaseVoucherType = "SPV" + } err = tx.QueryRow(ctx, ` - INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at) - VALUES ($1, $1, $2, $3, NOW()) + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, voucher_type_at_purchase) + VALUES ($1, $1, $2, $3, NOW(), $4) RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at - `, req.Amount, adminID, req.IsInventory).Scan( + `, req.Amount, adminID, req.IsInventory, purchaseVoucherType).Scan( &gc.ID, &gc.TotalFundsAdded, &gc.AmountRemaining, @@ -903,11 +908,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { var cardID string if req.RecipientType == "self" { + var purchaseVoucherType string + _ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) + if purchaseVoucherType == "" { + purchaseVoucherType = "SPV" + } err = tx.QueryRow(ctx, ` - INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory) - VALUES ($1, 0, $2, NOW(), $2, FALSE) + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, voucher_type_at_purchase) + VALUES ($1, 0, $2, NOW(), $2, FALSE, $3) RETURNING id - `, amountPounds, userID).Scan(&cardID) + `, amountPounds, userID, purchaseVoucherType).Scan(&cardID) if err != nil { log.Printf("Failed to insert gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -937,11 +947,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { return } } else { + var purchaseVoucherType string + _ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) + if purchaseVoucherType == "" { + purchaseVoucherType = "SPV" + } err = tx.QueryRow(ctx, ` - INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) - VALUES ($1, $1, $2, FALSE) + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase) + VALUES ($1, $1, $2, FALSE, $3) RETURNING id - `, amountPounds, userID).Scan(&cardID) + `, amountPounds, userID, purchaseVoucherType).Scan(&cardID) if err != nil { log.Printf("Failed to insert gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -982,16 +997,25 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { CreatedBy: &userID, } - _, err = tx.Exec(ctx, ` + var buyPaymentID string + err = tx.QueryRow(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - `, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt) + RETURNING id + `, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&buyPaymentID) if err != nil { log.Printf("Failed to insert payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } + vatCfg, vatErr := GetVATConfig(ctx, tx) + if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" { + if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", buyPaymentID, vatCfg.DefaultVATRate); vatExecErr != nil { + log.Printf("Failed to apply VAT to buy gift card payment %s: %v", buyPaymentID, vatExecErr) + } + } + if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit buy transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 227b684..8cbb083 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -89,6 +89,8 @@ type PaymentSummaryResponse struct { PaidAmount int64 `json:"paid_amount"` RefundedAmount int64 `json:"refunded_amount"` RemainingAmount int64 `json:"remaining_amount"` + TotalVATAmount int64 `json:"total_vat_amount"` + TotalNetAmount int64 `json:"total_net_amount"` Payments []PaymentResponse `json:"payments"` Refunds []RefundResponse `json:"refunds"` } @@ -383,6 +385,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal server error", http.StatusInternalServerError) return } + ApplyVATToBookingPayment(r.Context(), tx, paymentID) } else { // giftcard var customerID sql.NullString err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID) @@ -416,6 +419,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { } } + var cardVoucherType string // voucher_type_at_purchase from the gift card if !usedBalance { // Try direct card redemption (for guests or users without a redeemed balance) if req.GiftCardID == nil || *req.GiftCardID == "" { @@ -426,7 +430,8 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { var gcRemaining float64 var redeemedBy sql.NullString - err = tx.QueryRow(r.Context(), "SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1 FOR UPDATE", cleanCardID).Scan(&gcRemaining, &redeemedBy) + var vtp sql.NullString + err = tx.QueryRow(r.Context(), "SELECT amount_remaining, redeemed_by, voucher_type_at_purchase FROM gift_cards WHERE id = $1 FOR UPDATE", cleanCardID).Scan(&gcRemaining, &redeemedBy, &vtp) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Gift card not found", http.StatusNotFound) @@ -447,6 +452,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { return } + // Record the voucher_type_at_purchase for later VAT decision. + // Legacy cards (created before this column existed) have NULL → default to SPV. + if vtp.Valid { + cardVoucherType = vtp.String + } else { + cardVoucherType = "SPV" + } + // Deduct directly from card remaining amount _, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID) if err != nil { @@ -467,6 +480,21 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal server error", http.StatusInternalServerError) return } + + // Apply VAT at redemption only if the gift card was purchased as MPV + // (VAT deferred to redemption). For SPV, VAT was already paid at sale. + // For account balance payments (usedBalance=true), VAT was already paid + // when the original card was purchased. + if usedBalance { + // VAT already paid at purchase time — nothing to do here. + } else if cardVoucherType == "MPV" { + vatCfg, vatErr := GetVATConfig(r.Context(), tx) + if vatErr == nil && vatCfg.IsVATRegistered { + if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", paymentID, vatCfg.DefaultVATRate); vatExecErr != nil { + log.Printf("Failed to apply VAT to giftcard payment %s: %v", paymentID, vatExecErr) + } + } + } } if err := tx.Commit(r.Context()); err != nil { @@ -574,12 +602,27 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { UpdatedAt: time.Now(), } - paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) + tx, err := db.Conn.Begin(r.Context()) + if err != nil { + log.Printf("Failed to begin transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } + ApplyVATToBookingPayment(r.Context(), tx, paymentID) + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentStatusResponse{ @@ -886,6 +929,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { defer tx.Rollback(r.Context()) var primaryPaymentID string + var paymentIDs []string for i, rec := range records { pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil) if cErr != nil { @@ -893,11 +937,23 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal server error", http.StatusInternalServerError) return } + paymentIDs = append(paymentIDs, pid) if i == 0 { primaryPaymentID = pid } } + // Apply VAT to all split records if the business is VAT-registered. + // Must be inside the transaction so VAT updates are atomic with inserts. + vatCfg, vatErr := GetVATConfig(r.Context(), tx) + if vatErr == nil && vatCfg.IsVATRegistered { + for _, pid := range paymentIDs { + if _, execErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", pid, vatCfg.DefaultVATRate); execErr != nil { + log.Printf("Failed to apply VAT to payment %s: %v", pid, execErr) + } + } + } + // Promote deposit to confirmed if total paid meets the 20% threshold. // Check is inside the transaction so it sees the just-inserted payments. var depositMet bool @@ -1623,12 +1679,27 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { CreatedBy: &userID, } - paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil) + tx, err := db.Conn.Begin(r.Context()) + if err != nil { + log.Printf("Failed to begin transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } + ApplyVATToBookingPayment(r.Context(), tx, paymentID) + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentResponse{ @@ -1712,6 +1783,8 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) { PaidAmount: int64(summary.PaidAmount * 100), RefundedAmount: int64(summary.RefundedAmount * 100), RemainingAmount: int64(summary.RemainingAmount * 100), + TotalVATAmount: int64(summary.TotalVATAmount * 100), + TotalNetAmount: int64(summary.TotalNetAmount * 100), Payments: payments, Refunds: refunds, }) diff --git a/backend/handlers/payments/service.go b/backend/handlers/payments/service.go index 3b47195..3fe7e63 100644 --- a/backend/handlers/payments/service.go +++ b/backend/handlers/payments/service.go @@ -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 diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index 2003778..18e1c21 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -120,11 +120,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { var giftCardID string if req.Action == "create" { + var purchaseVoucherType string + _ = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) + if purchaseVoucherType == "" { + purchaseVoucherType = "SPV" + } err = tx.QueryRow(ctx, ` - INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) - VALUES ($1, $1, $2, FALSE) + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase) + VALUES ($1, $1, $2, FALSE, $3) RETURNING id - `, req.Amount, adminID).Scan(&giftCardID) + `, req.Amount, adminID, purchaseVoucherType).Scan(&giftCardID) if err != nil { log.Printf("Failed to create gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -245,23 +250,23 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { if req.IdempotencyKey == "" { req.IdempotencyKey = "till-cash-" + giftCardID + "-" + time.Now().Format("20060102150405.000000") } - case "saved_card": - dbPaymentMethod = "online_square" - if req.UserID != nil && *req.UserID != "" { - _, err = service.GetCardByID(ctx, *req.UserSavedCardID, *req.UserID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Saved card not found", http.StatusNotFound) + case "saved_card": + dbPaymentMethod = "online_square" + if req.UserID != nil && *req.UserID != "" { + _, err = service.GetCardByIDQuerier(ctx, tx, *req.UserSavedCardID, *req.UserID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Saved card not found", http.StatusNotFound) + return + } + log.Printf("Failed to verify saved card: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) return } - log.Printf("Failed to verify saved card: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return } - } var sqCardID string - err = db.Conn.QueryRow(ctx, ` + err = tx.QueryRow(ctx, ` SELECT square_card_id FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL @@ -395,6 +400,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { return } + if req.PaymentMethod != "on_the_house" && saleStatus == "completed" { + vatCfg, vatErr := GetVATConfig(ctx, tx) + if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" { + if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", tillSaleID, vatCfg.DefaultVATRate); vatExecErr != nil { + log.Printf("Failed to apply VAT to till sale %s: %v", tillSaleID, vatExecErr) + } + } + } + if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit till sale transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -458,7 +472,15 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) { } if paymentResult.Status == "COMPLETED" { - _, err = db.Conn.Exec(r.Context(), ` + tx, err := db.Conn.Begin(r.Context()) + if err != nil { + log.Printf("Failed to begin transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + _, err = tx.Exec(r.Context(), ` UPDATE till_sales SET status = 'completed', square_payment_id = $1, @@ -470,6 +492,13 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal server error", http.StatusInternalServerError) return } + ApplyVATToTillSale(r.Context(), tx, tillSaleID) + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PaymentStatusResponse{