Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
652 lines
22 KiB
Go
652 lines
22 KiB
Go
package payments
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
|
|
"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"`
|
|
CardToken string `json:"card_token,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"`
|
|
}
|
|
|
|
// uniqueTillKey generates a unique idempotency key for till sales where the
|
|
// client did not supply one. Dedup of retries is handled by the client-supplied
|
|
// key (the frontend sends a UUID); this fallback only needs to be unique so it
|
|
// never collides with the till_sales idempotency_key UNIQUE constraint.
|
|
// Deliberately NOT derived from request fields — two legitimate identical
|
|
// sales (e.g. two £50 cash gift-card creations) would hash to the same key.
|
|
func uniqueTillKey() string {
|
|
return "till-" + rand.Text()
|
|
}
|
|
|
|
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 {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", 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.CardToken == "" {
|
|
http.Error(w, "card_token is required when payment method is online_square — use a Square Web Payments nonce", 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
|
|
}
|
|
if req.Action == "topup" && req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
|
|
// Redeem is create-only. Topping up an unredeemed card (which can still
|
|
// carry residual balance) and then redeeming would zero amount_remaining
|
|
// while crediting the user only the top-up amount — destroying money.
|
|
// Fail loudly rather than silently ignoring the redeem.
|
|
http.Error(w, "Cannot redeem a top-up to a user account", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Idempotency check: if key provided, return existing sale if found
|
|
// Idempotency handling. A 'completed' sale is a dedup (return it). A
|
|
// 'pending' sale means the previous Square charge failed — the gift card
|
|
// was already funded in the committed transaction, so re-attempt the
|
|
// Square charge (Square dedups on the same key) and complete the sale.
|
|
// Mirrors the tip/gift-card pending-reuse pattern.
|
|
var existingPendingID string
|
|
var existingPendingGiftCard string
|
|
if req.IdempotencyKey != "" {
|
|
var existingID, existingStatus, existingItemID string
|
|
err := db.Conn.QueryRow(ctx, `SELECT id, status, item_id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus, &existingItemID)
|
|
if err == nil {
|
|
if existingStatus == "completed" {
|
|
if err := json.NewEncoder(w).Encode(TillSaleResponse{
|
|
ID: existingID,
|
|
ItemType: req.ItemType,
|
|
TotalAmount: req.Amount,
|
|
PaymentMethod: req.PaymentMethod,
|
|
Status: "completed",
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if existingStatus == "pending" {
|
|
existingPendingID = existingID
|
|
existingPendingGiftCard = existingItemID
|
|
}
|
|
}
|
|
}
|
|
|
|
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 func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
// Pending-retry: the gift card was already created and funded in the prior
|
|
// committed transaction, so skip the create/top-up and sale-insert blocks.
|
|
var giftCardID string
|
|
if existingPendingID != "" {
|
|
giftCardID = existingPendingGiftCard
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// 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").
|
|
// Runs only on the FIRST attempt of a create — a pending retry reuses the
|
|
// already-funded gift card and must never re-run this, or the user's balance
|
|
// would be credited a second time (money loss to the business).
|
|
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
|
|
}
|
|
}
|
|
} 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
|
|
}
|
|
}
|
|
|
|
penceAmount := int64(math.Round(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
|
|
|
|
// Pending-retry for card_machine: the original Square checkout may still be
|
|
// live at the terminal. If the pending till_sales row already recorded a
|
|
// square_checkout_id, reuse it instead of creating a second checkout — a
|
|
// fresh checkout would orphan the original, which can still complete and
|
|
// become an untracked charge.
|
|
var existingPendingCheckoutID string
|
|
if existingPendingID != "" {
|
|
err = tx.QueryRow(ctx, `SELECT COALESCE(square_checkout_id, '') FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID)
|
|
if err != nil {
|
|
log.Printf("Failed to query existing pending sale checkout: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
switch req.PaymentMethod {
|
|
case "cash":
|
|
saleStatus = "completed"
|
|
dbPaymentMethod = "cash"
|
|
if req.IdempotencyKey == "" {
|
|
req.IdempotencyKey = uniqueTillKey()
|
|
}
|
|
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 = uniqueTillKey()
|
|
}
|
|
|
|
saleStatus = "pending"
|
|
needsSquarePayment = true
|
|
case "card_machine":
|
|
dbPaymentMethod = "in_person_card"
|
|
if req.IdempotencyKey == "" {
|
|
req.IdempotencyKey = uniqueTillKey()
|
|
}
|
|
|
|
if existingPendingCheckoutID != "" {
|
|
// Pending retry — reuse the checkout already created for this sale
|
|
// instead of creating a second one. The original checkout may still
|
|
// be live at the terminal; a fresh checkout would orphan it into an
|
|
// untracked charge.
|
|
squareCheckoutID = &existingPendingCheckoutID
|
|
saleStatus = "pending"
|
|
} else {
|
|
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 = uniqueTillKey()
|
|
}
|
|
|
|
saleStatus = "pending"
|
|
needsSquarePayment = true
|
|
case "on_the_house":
|
|
saleStatus = "completed"
|
|
dbPaymentMethod = "on_the_house"
|
|
if req.IdempotencyKey == "" {
|
|
req.IdempotencyKey = uniqueTillKey()
|
|
}
|
|
}
|
|
|
|
desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount)
|
|
|
|
var tillSaleID string
|
|
if existingPendingID != "" {
|
|
// Reusing the pending sale row from a failed prior attempt — the sale
|
|
// was already inserted, so skip the insert and reuse its ID.
|
|
tillSaleID = existingPendingID
|
|
} else {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Commit the (possibly nested) transaction. In the pending-reuse path it is
|
|
// empty, but the commit releases the savepoint in the test harness so the
|
|
// deferred rollback does not undo the later status UPDATE.
|
|
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.
|
|
// Resolve buyer email for Square receipt delivery (non-fatal if missing).
|
|
var buyerEmail string
|
|
if req.UserID != nil && *req.UserID != "" {
|
|
if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, *req.UserID).Scan(&buyerEmail); err != nil {
|
|
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", *req.UserID, err)
|
|
}
|
|
}
|
|
if buyerEmail == "" {
|
|
if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, adminID).Scan(&buyerEmail); err != nil {
|
|
log.Printf("[SQUARE-PROD] Failed to resolve admin email for user %s: %v (Square receipts will not be emailed)", adminID, err)
|
|
}
|
|
}
|
|
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,
|
|
BuyerEmail: buyerEmail,
|
|
}
|
|
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
|
} else if req.PaymentMethod == "online_square" {
|
|
// PCI-DSS: raw PANs are never accepted. The admin till must supply a
|
|
// Square Web Payments nonce (cnon:xxx), tokenized via the Cards API.
|
|
if req.CardToken == "" {
|
|
log.Printf("online_square till sale missing card_token for gift card %s", giftCardID)
|
|
http.Error(w, "card_token is required for online_square payment — use a Square Web Payments nonce", http.StatusBadRequest)
|
|
return
|
|
}
|
|
cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken)
|
|
if cardErr != nil {
|
|
log.Printf("Failed to tokenize 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,
|
|
BuyerEmail: buyerEmail,
|
|
}
|
|
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"
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
if err := json.NewEncoder(w).Encode(TillSaleResponse{
|
|
ID: tillSaleID,
|
|
ItemType: req.ItemType,
|
|
ItemID: &giftCardID,
|
|
TotalAmount: req.Amount,
|
|
PaymentMethod: req.PaymentMethod,
|
|
Status: saleStatus,
|
|
CheckoutID: squareCheckoutID,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
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, ¤tStatus)
|
|
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" {
|
|
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
|
|
Status: "COMPLETED",
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID)
|
|
if err != nil {
|
|
if errors.Is(err, square.ErrCheckoutPending) {
|
|
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
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 func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, 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
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
|
|
Status: "COMPLETED",
|
|
PaymentID: tillSaleID,
|
|
Amount: paymentResult.Amount,
|
|
CardBrand: paymentResult.CardBrand,
|
|
CardLast4: paymentResult.CardLast4,
|
|
ReceiptURL: paymentResult.ReceiptURL,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|