Files
Crussell/backend/handlers/payments/till.go
T
popertotsandSisyphus 7b24f8e484 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>
2026-06-24 23:43:40 +01:00

539 lines
17 KiB
Go

package payments
import (
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
type TillSaleRequest struct {
ItemType string `json:"item_type" validate:"required"`
Action string `json:"action" validate:"required"`
Amount float64 `json:"amount" validate:"required,gt=0"`
GiftCardID *string `json:"gift_card_id,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required"`
UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
UserID *string `json:"user_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
CardNumber string `json:"card_number,omitempty"`
CardExpMonth int `json:"card_exp_month,omitempty"`
CardExpYear int `json:"card_exp_year,omitempty"`
CardCVC string `json:"card_cvc,omitempty"`
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
}
type TillSaleResponse struct {
ID string `json:"id"`
ItemType string `json:"item_type"`
ItemID *string `json:"item_id,omitempty"`
TotalAmount float64 `json:"total_amount"`
PaymentMethod string `json:"payment_method"`
Status string `json:"status"`
CheckoutID *string `json:"checkout_id,omitempty"`
}
func CreateTillSale(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
adminID, _ := ctx.Value(mw.UserIDKey).(string)
var req TillSaleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if req.ItemType != "gift_card" {
http.Error(w, "Unsupported item type", http.StatusBadRequest)
return
}
if req.Action != "create" && req.Action != "topup" {
http.Error(w, "Action must be 'create' or 'topup'", http.StatusBadRequest)
return
}
if req.Amount <= 0 {
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
return
}
if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" && req.PaymentMethod != "on_the_house" {
http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', 'online_square', or 'on_the_house'", http.StatusBadRequest)
return
}
if req.PaymentMethod == "saved_card" && (req.UserSavedCardID == nil || *req.UserSavedCardID == "") {
http.Error(w, "user_saved_card_id is required when payment method is saved_card", http.StatusBadRequest)
return
}
if req.PaymentMethod == "online_square" && req.CardNumber == "" {
http.Error(w, "card_number is required when payment method is online_square", http.StatusBadRequest)
return
}
if req.Action == "topup" && (req.GiftCardID == nil || *req.GiftCardID == "") {
http.Error(w, "gift_card_id is required for topup", http.StatusBadRequest)
return
}
// Idempotency check: if key provided, return existing sale if found
if req.IdempotencyKey != "" {
var existingID string
err := db.Conn.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID)
if err == nil {
// Existing sale found — return it (idempotent)
json.NewEncoder(w).Encode(TillSaleResponse{
ID: existingID,
ItemType: req.ItemType,
TotalAmount: req.Amount,
PaymentMethod: req.PaymentMethod,
Status: "completed",
})
return
}
}
service := NewPaymentService()
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(ctx)
var giftCardID string
if req.Action == "create" {
var purchaseVoucherType string
err = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
if err != nil {
log.Printf("Failed to query voucher type: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if purchaseVoucherType == "" {
purchaseVoucherType = "SPV"
}
err = tx.QueryRow(ctx, `
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, purchaseVoucherType).Scan(&giftCardID)
if err != nil {
log.Printf("Failed to create gift card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'purchase', $2, 'till_sale', NULL, $3, NULL)
`, giftCardID, req.Amount, req.UserID)
if err != nil {
log.Printf("Failed to create gift_card_transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
} else {
cardID := validators.NormalizeGiftCardCode(*req.GiftCardID)
var redeemedBy sql.NullString
err = tx.QueryRow(ctx, "SELECT redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Gift card not found", http.StatusNotFound)
return
}
log.Printf("Failed to check gift card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if redeemedBy.Valid {
http.Error(w, "Cannot top up a card that has been redeemed to an account", http.StatusBadRequest)
return
}
var isInventory bool
var previousTotal float64
err = tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&isInventory, &previousTotal)
if err != nil {
log.Printf("Failed to check gift card state: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET total_funds_added = total_funds_added + $1,
amount_remaining = amount_remaining + $1,
last_used_at = NOW()
WHERE id = $2
`, req.Amount, cardID)
if err != nil {
log.Printf("Failed to top up gift card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
transactionType := "topup"
var notes *string
if isInventory && previousTotal == 0 {
transactionType = "purchase"
n := "first top-up on inventory card"
notes = &n
}
_, err = tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, $2, $3, 'till_sale', NULL, $4, $5)
`, cardID, transactionType, req.Amount, req.UserID, notes)
if err != nil {
log.Printf("Failed to create gift_card_transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
giftCardID = cardID
}
// If the gift card should be immediately redeemed to a user's account balance
// (e.g. admin selected "add to account" rather than "generate gift code")
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = 0,
redeemed_at = NOW(),
redeemed_by = $1,
last_used_at = NOW()
WHERE id = $2
`, *req.RedeemToUserID, giftCardID)
if err != nil {
log.Printf("Failed to redeem gift card to user account: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, *req.RedeemToUserID, req.Amount)
if err != nil {
log.Printf("Failed to update user gift card balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
penceAmount := int64(req.Amount * 100)
var squarePaymentID *string
var squareCheckoutID *string
var saleStatus string
var dbPaymentMethod string
// Post-commit Square payment tracking: saved_card and online_square
// call Square AFTER the DB transaction commits, so a tx failure never
// leaves a Square charge with no DB record.
var needsSquarePayment bool
var savedCardSqCardID string
switch req.PaymentMethod {
case "cash":
saleStatus = "completed"
dbPaymentMethod = "cash"
if req.IdempotencyKey == "" {
req.IdempotencyKey = "till-cash-" + giftCardID + "-" + clock.Now().Format("20060102150405.000000")
}
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
}
}
err = tx.QueryRow(ctx, `
SELECT square_card_id
FROM user_saved_cards
WHERE id = $1 AND deleted_at IS NULL
`, *req.UserSavedCardID).Scan(&savedCardSqCardID)
if err != nil {
log.Printf("Failed to get saved card details: %v", err)
http.Error(w, "Card not found", http.StatusNotFound)
return
}
if req.IdempotencyKey == "" {
req.IdempotencyKey = "till-sale-" + giftCardID + "-" + clock.Now().Format("20060102150405.000000")
}
saleStatus = "pending"
needsSquarePayment = true
case "card_machine":
dbPaymentMethod = "in_person_card"
if req.IdempotencyKey == "" {
req.IdempotencyKey = "till-terminal-" + giftCardID + "-" + clock.Now().Format("20060102150405.000000")
}
checkoutReq := square.CreateCheckoutReq{
Amount: penceAmount,
Currency: "GBP",
IdempotencyKey: req.IdempotencyKey,
ReferenceID: giftCardID,
TipEnabled: false,
}
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
if err != nil {
log.Printf("Failed to create Square checkout: %v", err)
http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError)
return
}
squareCheckoutID = &checkout.ID
saleStatus = "pending"
case "online_square":
dbPaymentMethod = "online_square"
if req.IdempotencyKey == "" {
req.IdempotencyKey = "till-online-" + giftCardID + "-" + clock.Now().Format("20060102150405.000000")
}
saleStatus = "pending"
needsSquarePayment = true
case "on_the_house":
saleStatus = "completed"
dbPaymentMethod = "on_the_house"
if req.IdempotencyKey == "" {
req.IdempotencyKey = "till-on-the-house-" + giftCardID + "-" + clock.Now().Format("20060102150405.000000")
}
}
desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount)
var tillSaleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (
item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id,
square_payment_id, square_checkout_id, idempotency_key, notes, created_by, created_at, updated_at
) VALUES ($1, $2, $3, 1, $4, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
RETURNING id
`,
req.ItemType,
giftCardID,
desc,
req.Amount,
dbPaymentMethod,
saleStatus,
req.UserID,
req.UserSavedCardID,
squarePaymentID,
squareCheckoutID,
req.IdempotencyKey,
"Admin till sale: "+req.Action+" gift card",
adminID,
).Scan(&tillSaleID)
if err != nil {
log.Printf("Failed to insert till sale: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(ctx, `
UPDATE gift_card_transactions SET reference_id = $1
WHERE gift_card_id = $2 AND reference_id IS NULL AND created_at > NOW() - INTERVAL '5 seconds'
`, tillSaleID, giftCardID)
if err != nil {
log.Printf("Failed to update gift_card_transactions reference: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.PaymentMethod != "on_the_house" && (saleStatus == "completed" || needsSquarePayment) {
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)
return
}
// Step 2: DB transaction committed — safe to call Square now.
// If Square fails, the till_sale record stays 'pending' for manual retry.
if needsSquarePayment {
var paymentResult *square.PaymentResult
var squareErr error
if req.PaymentMethod == "saved_card" {
paymentReq := square.CreatePaymentReq{
Amount: penceAmount,
Currency: "GBP",
SourceID: savedCardSqCardID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
}
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
} else if req.PaymentMethod == "online_square" {
cardOnFile, cardErr := SquareClient.CreateCardOnFileRaw(ctx, "till-"+giftCardID, req.CardNumber, req.CardExpMonth, req.CardExpYear, req.CardCVC)
if cardErr != nil {
log.Printf("Failed to tokenize ephemeral card: %v", cardErr)
http.Error(w, "Card tokenization failed", http.StatusInternalServerError)
return
}
paymentReq := square.CreatePaymentReq{
Amount: penceAmount,
Currency: "GBP",
SourceID: cardOnFile.CardID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
}
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
}
if squareErr != nil {
log.Printf("Failed to process payment: %v", squareErr)
http.Error(w, "Payment failed", http.StatusPaymentRequired)
return
}
// Square succeeded — update the till_sale record.
_, upErr := db.Conn.Exec(ctx,
`UPDATE till_sales SET status = 'completed', square_payment_id = $1 WHERE id = $2`,
paymentResult.SquarePayID, tillSaleID,
)
if upErr != nil {
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but till_sale %s update failed: %v — manual reconciliation required", paymentResult.SquarePayID, tillSaleID, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
saleStatus = "completed"
squarePaymentID = &paymentResult.SquarePayID
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(TillSaleResponse{
ID: tillSaleID,
ItemType: req.ItemType,
ItemID: &giftCardID,
TotalAmount: req.Amount,
PaymentMethod: req.PaymentMethod,
Status: saleStatus,
CheckoutID: squareCheckoutID,
})
}
func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
http.Error(w, "Checkout ID is required", http.StatusBadRequest)
return
}
var tillSaleID string
var currentStatus string
err := db.Conn.QueryRow(r.Context(), `
SELECT id, status FROM till_sales
WHERE square_checkout_id = $1
`, checkoutID).Scan(&tillSaleID, &currentStatus)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Till sale not found", http.StatusNotFound)
return
}
log.Printf("Failed to find till sale: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if currentStatus == "completed" {
json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
})
return
}
paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID)
if err != nil {
if err.Error() == "checkout pending" {
json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"})
return
}
log.Printf("Failed to get checkout status: %v", err)
http.Error(w, "Failed to get checkout status", http.StatusInternalServerError)
return
}
if paymentResult.Status == "COMPLETED" {
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,
updated_at = NOW()
WHERE id = $2
`, paymentResult.SquarePayID, tillSaleID)
if err != nil {
log.Printf("Failed to update till sale: %v", err)
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
}
json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: tillSaleID,
Amount: paymentResult.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
})
return
}
json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"})
}