Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1913 lines
90 KiB
Go
1913 lines
90 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/internal/twofa"
|
|
"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"`
|
|
// NewCardToken is the SCA tokenize-result token (card.tokenize(
|
|
// verificationDetails, cardId)) for a saved-card till sale. When present it
|
|
// coexists with user_saved_card_id: the token is the one-time charge SOURCE
|
|
// and the saved-card row supplies the customer (mirrors the booking SCA
|
|
// tokenize-result wire contract). Without it the stored ccof: card id is
|
|
// the source (legacy saved-card charge).
|
|
NewCardToken *string `json:"new_card_token,omitempty"`
|
|
// IdempotencyKey is optional; an empty key is replaced with a DETERMINISTIC
|
|
// fallback derived from the canonical request fields
|
|
// (deriveTillIdempotencyKey) so a lost-response retry re-derives the SAME
|
|
// key and reuses the pending till_sale row instead of minting a second
|
|
// Square charge. Limit 45: this key feeds CreatePayment (Square's /v2/
|
|
// payments cap) as well as CreateCheckout, which allows 64 — the stricter
|
|
// 45 applies because the same key is replayed to /v2/payments.
|
|
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
|
|
CardToken string `json:"card_token,omitempty"`
|
|
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
|
|
VerificationToken *string `json:"verification_token,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"`
|
|
}
|
|
|
|
// uniqueChargeKey (handlers.go) remains the random fallback for the TIP flow,
|
|
// where two identical no-key tips are distinct operations that must diverge.
|
|
// The till no-key fallback deliberately does NOT use it: a random key would
|
|
// mint a second Square charge (and second gift-card funding) when a lost-
|
|
// response create is retried. deriveTillIdempotencyKey (below) derives a
|
|
// request-stable key instead.
|
|
|
|
// definitivePaymentDeclineCodes are Square payment error codes meaning the
|
|
// card charge can never succeed (declined / expired / not supported / SCA
|
|
// verification required). They are matched against the formatted Square API
|
|
// error message so a DEFINITIVE rejection can claw back a gift card funded
|
|
// earlier in the same till-sale request. Anything else (transport errors, 5xx,
|
|
// unknown) is treated as ambiguous: the sale is left pending for the
|
|
// stale-pending sweep, which may still resolve it.
|
|
//
|
|
// A1: this list is the MESSAGE-MATCH fallback ONLY. The authoritative
|
|
// structured-code classification lives in the square package's
|
|
// definitivePaymentCodes / IsDefinitivePaymentError
|
|
// (square_http_client.go:777-818) — the single source of truth that also
|
|
// carries the SCA buyer-verification codes — and isDefinitiveChargeFailure
|
|
// delegates to it FIRST. The list below must stay a subset-compatible mirror
|
|
// for errors that carry no structured code (the dev mock's plain errors, a
|
|
// non-JSON failure body), where the formatted "[CATEGORY/CODE]" message is the
|
|
// only signal available.
|
|
var definitivePaymentDeclineCodes = []string{
|
|
"CARD_DECLINED",
|
|
"CARD_EXPIRED",
|
|
"INVALID_EXPIRATION",
|
|
"INVALID_EXPIRATION_DATE",
|
|
"CARD_NOT_SUPPORTED",
|
|
"VERIFY_CVV_FAILURE",
|
|
"AVS_FAILURE",
|
|
"PAYMENT_CARD_DECLINED",
|
|
"GENERIC_DECLINE",
|
|
"INSUFFICIENT_FUNDS",
|
|
"ADDRESS_VERIFICATION_FAILURE",
|
|
"TRANSACTION_LIMIT",
|
|
// Square's specific CARD_DECLINED_* decline reasons. This list must stay a
|
|
// SUPERSET-MATCHED MIRROR of square_http_client.go:definitivePaymentCodes
|
|
// (the single source of truth) — every variant the square package treats as
|
|
// definitive must also match here, so a dev-mock plain error carrying e.g.
|
|
// CARD_DECLINED_INSUFFICIENT_FUNDS in its formatted message classifies
|
|
// definitively. Add any new definitivePaymentCodes entry here too.
|
|
"CARD_DECLINED_CALL_ISSUER",
|
|
"CARD_DECLINED_AVS_FAILURE",
|
|
"CARD_DECLINED_CVV_FAILURE",
|
|
"CARD_DECLINED_INSUFFICIENT_FUNDS",
|
|
"CARD_DECLINED_INVALID_ACCOUNT",
|
|
"CARD_DECLINED_INVALID_AMOUNT",
|
|
"CARD_DECLINED_CARD_EXPIRED",
|
|
"CARD_DECLINED_PIN_RETRIES_EXCEEDED",
|
|
// SCA / buyer-verification codes — the buyer must re-verify or the card be
|
|
// re-tokenized before the charge can succeed; retrying is pointless. Kept
|
|
// in the message fallback so the dev mock's plain errors classify exactly
|
|
// like the real client's structured codes.
|
|
"CARD_DECLINED_VERIFICATION_REQUIRED",
|
|
"VERIFICATION_TOKEN_EXPIRED",
|
|
"VERIFICATION_TOKEN_INVALID",
|
|
"CVV_VERIFICATION_REQUIRED",
|
|
"ADDRESS_VERIFICATION_REQUIRED",
|
|
"MISSING_PIN",
|
|
"MISSING_VERIFICATION_TOKEN",
|
|
}
|
|
|
|
// isDefinitiveChargeFailure reports whether a Square CreatePayment error is a
|
|
// definitive business rejection (declined/expired/SCA-required) rather than an
|
|
// ambiguous transport/server error.
|
|
//
|
|
// A1 — single source of truth: the classification delegates FIRST to the
|
|
// square package's exported IsDefinitivePaymentError (square_http_client.go
|
|
// definitivePaymentCodes), which is the union of the card decline codes and
|
|
// the SCA buyer-verification codes. Delegating means the two parallel lists
|
|
// can never drift again — an SCA rejection (e.g. CVV_VERIFICATION_REQUIRED)
|
|
// now classifies as definitive here exactly as it does everywhere else, so the
|
|
// till's gift-card clawback (till.go:1184) reverses the funding on a
|
|
// verification failure the same way it does on a plain decline. Only errors
|
|
// that carry NO structured code (the dev mock's plain errors, or a non-JSON
|
|
// failure body) fall back to the legacy formatted-message match
|
|
// ("square: POST /v2/payments: [CATEGORY/CODE] ...") against
|
|
// definitivePaymentDeclineCodes — the only signal available for them.
|
|
func isDefinitiveChargeFailure(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if square.IsDefinitivePaymentError(err) {
|
|
return true
|
|
}
|
|
// Any other structured Square error code is authoritative — never
|
|
// substring-match its message.
|
|
if square.ErrorCode(err) != "" {
|
|
return false
|
|
}
|
|
msg := strings.ToUpper(err.Error())
|
|
for _, code := range definitivePaymentDeclineCodes {
|
|
if strings.Contains(msg, code) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// deriveTillIdempotencyKey returns the deterministic no-client-key fallback
|
|
// BASE idempotency key for a till sale: "till-" + sha256 over the canonical
|
|
// request fields (action, created_by admin, amount in pence, and the gift card
|
|
// / user / saved card / redeem targets when present), truncated to 16 bytes so
|
|
// the key stays within Square's 45-char /v2/payments limit.
|
|
//
|
|
// The hash is applied UNCONDITIONALLY (never the verbatim candidate): a row
|
|
// created pre-deploy stores the hashed form (CHAR(12) candidates like
|
|
// "till:create:...:2500" are ~30 chars and used to derive the hashed key), and
|
|
// a lost-response retry must re-derive the SAME key to hit the dedup SELECT
|
|
// and slot scan — the length-conditional truncateIdempotencyKey form would
|
|
// switch short candidates to a raw key that misses the pre-deploy row and
|
|
// mints a SECOND Square charge.
|
|
//
|
|
// A lost-response retry re-derives the SAME base key, so the idempotency lookup
|
|
// in CreateTillSale reuses the pending till_sale row and Square dedups on the
|
|
// key — ONE charge and ONE gift-card funding instead of the old uniqueChargeKey
|
|
// fallback's second charge on retry. The card nonce (req.CardToken) is
|
|
// deliberately EXCLUDED — it changes between retries — and no random value
|
|
// enters the base key, so identical logical sales always collide on the SAME
|
|
// base key; the handler resolves that collision through the slot scan
|
|
// (scanTillIdempotencyKeySlot) run under the advisory lock, which diverges
|
|
// genuinely DISTINCT identical keyless sales onto distinct final keys (F5)
|
|
// while a lost-response retry of a pending sale keeps re-deriving the same
|
|
// candidate and reuses the pending row.
|
|
func deriveTillIdempotencyKey(req TillSaleRequest, adminID string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("till:")
|
|
sb.WriteString(req.Action)
|
|
sb.WriteString(":")
|
|
sb.WriteString(adminID)
|
|
sb.WriteString(":")
|
|
sb.WriteString(strconv.FormatInt(int64(math.Round(req.Amount*100)), 10))
|
|
// NOTE: float64→int64 pence conversion can drift by ±1p between charge and replay.
|
|
// A full fix would store pence as int64 throughout (out of scope for this change).
|
|
if req.GiftCardID != nil && *req.GiftCardID != "" {
|
|
sb.WriteString(":gc:")
|
|
sb.WriteString(validators.NormalizeGiftCardCode(*req.GiftCardID))
|
|
}
|
|
if req.UserID != nil && *req.UserID != "" {
|
|
sb.WriteString(":user:")
|
|
sb.WriteString(*req.UserID)
|
|
}
|
|
if req.UserSavedCardID != nil && *req.UserSavedCardID != "" {
|
|
sb.WriteString(":card:")
|
|
sb.WriteString(*req.UserSavedCardID)
|
|
}
|
|
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
|
|
sb.WriteString(":redeem:")
|
|
sb.WriteString(*req.RedeemToUserID)
|
|
}
|
|
// Always-hashed, NEVER truncateIdempotencyKey: a pre-deploy row stores the
|
|
// hashed key, so a raw-key re-derivation would miss the dedup and double-
|
|
// charge. The slot-scan candidates (via nextIdempotencyCandidate) keep their
|
|
// own conditional truncation, matching the pre-batch code.
|
|
sum := sha256.Sum256([]byte(sb.String()))
|
|
return "till-" + hex.EncodeToString(sum[:16])
|
|
}
|
|
|
|
// scanTillIdempotencyKeySlot resolves the FINAL deterministic idempotency key
|
|
// for a keyless Square-method till sale, mirroring the gift-card slot pattern
|
|
// (deriveGiftCardIdempotencyKey). Must be called under the crussell:till
|
|
// advisory lock so the scan-and-insert races no concurrent identical create.
|
|
//
|
|
// A COMPLETED (or swept/declined FAILED) sale occupies its candidate slot and
|
|
// forces the NEXT candidate — two genuine identical keyless sales (e.g. two
|
|
// £20 walk-in card creations with no user) must diverge onto distinct keys, or
|
|
// the second is silently swallowed by the first's dedup and its customer gets
|
|
// NO gift card (F5). A PENDING sale never occupies a slot: a lost-response
|
|
// retry re-derives the same candidate, the idempotency lookup below reuses the
|
|
// pending row and adopts its STORED key, and Square dedups the charge onto the
|
|
// original — ONE charge, ONE gift-card funding.
|
|
func scanTillIdempotencyKeySlot(ctx context.Context, baseKey string) (string, error) {
|
|
return scanIdempotencySlot(ctx, baseKey, func(candidate string) (bool, error) {
|
|
var status string
|
|
err := db.Conn.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, candidate).Scan(&status)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to scan till idempotency-key slot for %s: %w", baseKey, err)
|
|
}
|
|
// A COMPLETED (or swept/declined FAILED) sale occupies its candidate
|
|
// slot; PENDING never occupies it — a lost-response retry reuses the
|
|
// pending row, so the candidate stays free for that reuse.
|
|
return status == "completed" || status == "failed", nil
|
|
})
|
|
}
|
|
|
|
// refreshTillSnapshotSource rewrites the source_id field inside the stored
|
|
// square_request_snapshot JSON on a reused pending till sale so the snapshot
|
|
// stays consistent with the square_source_id column refreshed for the new
|
|
// charge attempt. The stale-pending sweep replays the snapshot VERBATIM as the
|
|
// charge body (sweep.go reconcileStalePaymentByKey); a snapshot whose source
|
|
// differs from the row's source would make Square return IDEMPOTENCY_KEY_REUSED
|
|
// for the retained key and strand the row pending forever. Runs inside the same
|
|
// transaction as the square_source_id refresh so the two columns never diverge.
|
|
// A row without a stored snapshot (or an unparseable one) is left untouched —
|
|
// the charge attempt below re-marshals a fresh full body before calling Square.
|
|
func refreshTillSnapshotSource(ctx context.Context, tx pgx.Tx, tillSaleID, newSource string) {
|
|
var snap sql.NullString
|
|
if err := tx.QueryRow(ctx, `SELECT square_request_snapshot FROM till_sales WHERE id = $1`, tillSaleID).Scan(&snap); err != nil {
|
|
log.Printf("Failed to read square_request_snapshot for reused till sale %s: %v", tillSaleID, err)
|
|
return
|
|
}
|
|
if !snap.Valid || snap.String == "" {
|
|
return
|
|
}
|
|
// The stored snapshot is AES-256-GCM-encrypted at rest in non-mock
|
|
// deployments (encryptSnapshot's "enc:v1:" marker) and plaintext in
|
|
// dev/mock — decrypt it first so the JSON mutation below operates on the
|
|
// request body, then re-encrypt on the way out so the stored form stays
|
|
// consistent with the other snapshot writes.
|
|
body := []byte(snap.String)
|
|
if !IsExplicitDevOrMockEnv() {
|
|
dec, dErr := decryptSnapshot(body)
|
|
if dErr != nil {
|
|
log.Printf("Failed to decrypt square_request_snapshot for reused till sale %s: %v", tillSaleID, dErr)
|
|
return
|
|
}
|
|
body = dec
|
|
}
|
|
var req square.CreatePaymentReq
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
log.Printf("Failed to parse square_request_snapshot for reused till sale %s: %v", tillSaleID, err)
|
|
return
|
|
}
|
|
req.SourceID = newSource
|
|
updated, err := json.Marshal(req)
|
|
if err != nil {
|
|
log.Printf("Failed to re-marshal square_request_snapshot for reused till sale %s: %v", tillSaleID, err)
|
|
return
|
|
}
|
|
stored := updated
|
|
if !IsExplicitDevOrMockEnv() {
|
|
enc, eErr := encryptSnapshot(updated)
|
|
if eErr != nil {
|
|
log.Printf("Failed to encrypt square_request_snapshot for reused till sale %s: %v", tillSaleID, eErr)
|
|
return
|
|
}
|
|
stored = enc
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); err != nil {
|
|
log.Printf("Failed to refresh square_request_snapshot for reused till sale %s: %v", tillSaleID, err)
|
|
}
|
|
}
|
|
|
|
// errTillSaleNotPending: the claim-first gating UPDATE matched zero rows, so
|
|
// the sale is no longer 'pending' and the gift card must be left untouched.
|
|
var errTillSaleNotPending = errors.New("till sale is not pending")
|
|
|
|
// tillSaleHasLiveRefund reports whether the till sale has a refund in a state
|
|
// meaning its money is no longer fully live: a completed refund (money
|
|
// returned) or a pending refund (money in flight). Failed refunds never moved
|
|
// money and are excluded. A till sale has no payments row of its own (refunds
|
|
// are FK'd to payments), so its refunds are matched by the sweep's
|
|
// deterministic reason string (sweepDuplicateRefundReasonFor) — the same
|
|
// linkage the B1 re-poll and in-flight guard use. Used to re-validate the till
|
|
// completed-dedup hit, mirroring paymentHasLiveRefund on the booking paths.
|
|
func tillSaleHasLiveRefund(ctx context.Context, q db.Querier, tillSaleID string) (bool, error) {
|
|
var exists bool
|
|
err := q.QueryRow(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM refunds WHERE reason = $1 AND status IN ('completed', 'pending')
|
|
)
|
|
`, sweepDuplicateRefundReasonFor(tillSaleID)).Scan(&exists)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// revertGiftCardFunding is the package-internal wrapper over the shared
|
|
// RevertGiftCardFunding clawback helper (giftcard_clawback.go). The sweep and
|
|
// the till handler both call it so every clawback path runs the single
|
|
// money-reversal implementation.
|
|
func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
|
|
return RevertGiftCardFunding(ctx, action, giftCardID, amount, redeemToUserID, tillSaleID)
|
|
}
|
|
|
|
// recreditTillSaleFundingAfterMidFlightResolve restores a till sale's gift-card
|
|
// funding after a concurrent clawback reverted it while a same-key retry's
|
|
// Square charge was mid-flight (FIX 1). The retry's charge HAS landed, so the
|
|
// customer paid for a card that was clawed back — this re-credits the funding
|
|
// so the money is not lost, turning "charged AND clawed back" into "charged
|
|
// AND funded". It runs BEFORE the handler fails the response; the sale row
|
|
// itself stays 'failed' (completing it would race the writer that resolved it)
|
|
// and is left for manual reconciliation.
|
|
//
|
|
// Re-credit is strictly conditional so it can never DOUBLE-fund a card:
|
|
// - the charge must be CONFIRMED at Square (re-query GetPayment) — a charge
|
|
// that did not land has nothing to fund;
|
|
// - the funding must actually have been CLAWED BACK — a create's card was
|
|
// deleted (row gone) and a topup's amount was subtracted with its
|
|
// transaction removed. If the funding is still intact (the sale was
|
|
// resolved without a clawback, e.g. a blind-fail) there is nothing to
|
|
// restore and re-crediting would double the value.
|
|
//
|
|
// Returns true when the funding was re-credited; false when the charge could
|
|
// not be confirmed, the funding was never clawed back, or the re-credit
|
|
// failed (CRITICAL logged for manual reconciliation).
|
|
func recreditTillSaleFundingAfterMidFlightResolve(ctx context.Context, req TillSaleRequest, tillSaleID, giftCardID string, pr *square.PaymentResult) bool {
|
|
if pr == nil || pr.SquarePayID == "" {
|
|
return false
|
|
}
|
|
// Confirm the charge landed at Square (the retry just created it, but the
|
|
// re-query makes the decision authoritative rather than trusting the
|
|
// response alone).
|
|
confirm, cErr := SquareClient.GetPayment(ctx, pr.SquarePayID)
|
|
if cErr != nil || confirm == nil || confirm.Status != "COMPLETED" {
|
|
log.Printf("CRITICAL: till sale %s charge %s could not be confirmed at Square (re-query: %v) — gift-card funding NOT re-credited — MANUAL RECONCILIATION REQUIRED", tillSaleID, pr.SquarePayID, cErr)
|
|
return false
|
|
}
|
|
if req.Action == "create" {
|
|
// A create's clawback DELETED the card. If it still exists the funding
|
|
// was never reverted — nothing to restore (re-creating would double it).
|
|
var cardExists bool
|
|
if err := db.Conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gift_cards WHERE id = $1)`, giftCardID).Scan(&cardExists); err != nil {
|
|
log.Printf("Failed to check gift card %s for mid-flight funding re-credit: %v", giftCardID, err)
|
|
return false
|
|
}
|
|
if cardExists {
|
|
return false
|
|
}
|
|
return recreditClawedBackCreateFunding(ctx, req, tillSaleID, giftCardID)
|
|
}
|
|
// Topup: the clawback removed THIS sale's top-up transaction. If it still
|
|
// exists the funding was never reverted — nothing to restore.
|
|
var txExists bool
|
|
if err := db.Conn.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2)
|
|
`, giftCardID, tillSaleID).Scan(&txExists); err != nil {
|
|
log.Printf("Failed to check top-up transaction for mid-flight funding re-credit: %v", err)
|
|
return false
|
|
}
|
|
if txExists {
|
|
return false
|
|
}
|
|
return recreditClawedBackTopupFunding(ctx, req, tillSaleID, giftCardID)
|
|
}
|
|
|
|
// recreditClawedBackCreateFunding re-creates the gift card a create-sale
|
|
// clawback deleted (the charge landed after the clawback), mirroring the
|
|
// create funding block of CreateTillSale: a fresh card for the amount, the
|
|
// purchase transaction, the redeem-to-account credit, and the till_sales
|
|
// item_id re-pointed at the new card so the reference stays valid. The new
|
|
// card gets a fresh auto-generated code — the original code is gone with the
|
|
// deleted row. All writes commit in ONE transaction so a crash cannot leave a
|
|
// funded card with no sale linkage. Returns true on success.
|
|
func recreditClawedBackCreateFunding(ctx context.Context, req TillSaleRequest, tillSaleID, giftCardID string) bool {
|
|
expiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, db.Conn)
|
|
if expiryErr != nil {
|
|
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
|
expiryMonths = defaultGiftCardExpiryMonths
|
|
}
|
|
purchaseVoucherType := "SPV"
|
|
if err := db.Conn.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType); err != nil {
|
|
log.Printf("Failed to query voucher type: %v", err)
|
|
}
|
|
if purchaseVoucherType == "" {
|
|
purchaseVoucherType = "SPV"
|
|
}
|
|
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
|
|
|
|
var createdBy string
|
|
if err := db.Conn.QueryRow(ctx, `SELECT created_by FROM till_sales WHERE id = $1`, tillSaleID).Scan(&createdBy); err != nil {
|
|
log.Printf("Failed to read till sale %s created_by for funding re-credit: %v", tillSaleID, err)
|
|
return false
|
|
}
|
|
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin funding re-credit transaction for till sale %s: %v", tillSaleID, err)
|
|
return false
|
|
}
|
|
defer func() {
|
|
if rErr := tx.Rollback(ctx); rErr != nil && !errors.Is(rErr, pgx.ErrTxClosed) {
|
|
log.Printf("Failed to rollback funding re-credit transaction for till sale %s: %v", tillSaleID, rErr)
|
|
}
|
|
}()
|
|
|
|
var newCardID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
|
|
VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
|
|
RETURNING id
|
|
`, req.Amount, createdBy, purchaseVoucherType, expiryMonths).Scan(&newCardID); err != nil {
|
|
log.Printf("CRITICAL: failed to re-create gift card for mid-flight create clawback of till sale %s: %v — MANUAL RECONCILIATION REQUIRED", tillSaleID, err)
|
|
return false
|
|
}
|
|
if _, 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', $3, $4, NULL)
|
|
`, newCardID, req.Amount, tillSaleID, req.UserID); err != nil {
|
|
log.Printf("CRITICAL: failed to record the re-credited gift-card purchase for till sale %s: %v — MANUAL RECONCILIATION REQUIRED", tillSaleID, err)
|
|
return false
|
|
}
|
|
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE gift_cards
|
|
SET amount_remaining = 0,
|
|
redeemed_at = NOW(),
|
|
redeemed_by = $1,
|
|
last_used_at = NOW(),
|
|
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
|
WHERE id = $2
|
|
`, *req.RedeemToUserID, newCardID, expiryMonths); err != nil {
|
|
log.Printf("CRITICAL: failed to re-apply the redeem for re-credited gift card %s (till sale %s): %v — MANUAL RECONCILIATION REQUIRED", newCardID, tillSaleID, err)
|
|
return false
|
|
}
|
|
if _, 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); err != nil {
|
|
log.Printf("CRITICAL: failed to re-credit the redeemed balance for re-credited gift card %s (till sale %s): %v — MANUAL RECONCILIATION REQUIRED", newCardID, tillSaleID, err)
|
|
return false
|
|
}
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE till_sales SET item_id = $1 WHERE id = $2`, newCardID, tillSaleID); err != nil {
|
|
log.Printf("CRITICAL: failed to re-point till sale %s at the re-credited gift card %s: %v — MANUAL RECONCILIATION REQUIRED", tillSaleID, newCardID, err)
|
|
return false
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("CRITICAL: failed to commit the gift-card funding re-credit for till sale %s: %v — MANUAL RECONCILIATION REQUIRED", tillSaleID, err)
|
|
return false
|
|
}
|
|
log.Printf("Re-credited gift-card funding for till sale %s: re-created card %s (create %s after a mid-flight clawback — original card %s was deleted)", tillSaleID, newCardID, req.Action, giftCardID)
|
|
return true
|
|
}
|
|
|
|
// recreditClawedBackTopupFunding re-adds the amount a topup-sale clawback
|
|
// subtracted, mirroring the top-up funding block of CreateTillSale: the amount
|
|
// is added back to the card's funds and this sale's top-up transaction is
|
|
// re-inserted. All writes commit in ONE transaction. Returns true on success.
|
|
func recreditClawedBackTopupFunding(ctx context.Context, req TillSaleRequest, tillSaleID, giftCardID string) bool {
|
|
// A concurrent gift-card cancellation can have removed the card entirely;
|
|
// then the funding cannot be re-credited.
|
|
var cardExists bool
|
|
if err := db.Conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gift_cards WHERE id = $1)`, giftCardID).Scan(&cardExists); err != nil {
|
|
log.Printf("Failed to check gift card %s for mid-flight funding re-credit: %v", giftCardID, err)
|
|
return false
|
|
}
|
|
if !cardExists {
|
|
log.Printf("CRITICAL: till sale %s top-up funding cannot be re-credited — gift card %s no longer exists — MANUAL RECONCILIATION REQUIRED", tillSaleID, giftCardID)
|
|
return false
|
|
}
|
|
|
|
expiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, db.Conn)
|
|
if expiryErr != nil {
|
|
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
|
expiryMonths = defaultGiftCardExpiryMonths
|
|
}
|
|
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to begin funding re-credit transaction for till sale %s: %v", tillSaleID, err)
|
|
return false
|
|
}
|
|
defer func() {
|
|
if rErr := tx.Rollback(ctx); rErr != nil && !errors.Is(rErr, pgx.ErrTxClosed) {
|
|
log.Printf("Failed to rollback funding re-credit transaction for till sale %s: %v", tillSaleID, rErr)
|
|
}
|
|
}()
|
|
|
|
var isInventory bool
|
|
var previousTotal float64
|
|
if err := tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added FROM gift_cards WHERE id = $1`, giftCardID).Scan(&isInventory, &previousTotal); err != nil {
|
|
log.Printf("CRITICAL: failed to read gift card %s for the top-up funding re-credit (till sale %s): %v — MANUAL RECONCILIATION REQUIRED", giftCardID, tillSaleID, err)
|
|
return false
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE gift_cards
|
|
SET total_funds_added = total_funds_added + $1,
|
|
amount_remaining = amount_remaining + $1,
|
|
last_used_at = NOW(),
|
|
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
|
WHERE id = $2
|
|
`, req.Amount, giftCardID, expiryMonths); err != nil {
|
|
log.Printf("CRITICAL: failed to re-credit the top-up amount on gift card %s (till sale %s): %v — MANUAL RECONCILIATION REQUIRED", giftCardID, tillSaleID, err)
|
|
return false
|
|
}
|
|
|
|
transactionType := "topup"
|
|
var notes *string
|
|
if isInventory && previousTotal == 0 {
|
|
transactionType = "purchase"
|
|
n := "first top-up on inventory card"
|
|
notes = &n
|
|
}
|
|
if _, 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', $4, $5, $6)
|
|
`, giftCardID, transactionType, req.Amount, tillSaleID, req.UserID, notes); err != nil {
|
|
log.Printf("CRITICAL: failed to re-insert the top-up transaction for till sale %s (gift card %s): %v — MANUAL RECONCILIATION REQUIRED", tillSaleID, giftCardID, err)
|
|
return false
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("CRITICAL: failed to commit the gift-card funding re-credit for till sale %s: %v — MANUAL RECONCILIATION REQUIRED", tillSaleID, err)
|
|
return false
|
|
}
|
|
log.Printf("Re-credited gift-card funding for till sale %s: added back £%.2f on card %s (topup after a mid-flight clawback)", tillSaleID, req.Amount, giftCardID)
|
|
return true
|
|
}
|
|
|
|
func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
// Defense-in-depth admin check (S-1) — a till sale moves money (charges a
|
|
// card / funds a gift card), so it must stay admin-only.
|
|
if !isAdminRequest(r) {
|
|
http.Error(w, "Admin access required", http.StatusForbidden)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
// C2: apply the shared £10,000 cap used by every other money entry point
|
|
// (validators.ValidateAmount). The till amount is pounds float64; validate
|
|
// the effective pence figure exactly as the Square charge amount below is
|
|
// derived (math.Round(req.Amount * 100)).
|
|
amountPence := int64(math.Round(req.Amount * 100))
|
|
// NOTE: float64→int64 pence conversion can drift by ±1p between charge and replay.
|
|
// A full fix would store pence as int64 throughout (out of scope for this change).
|
|
// Gift-card creates/topups are additionally capped at £250 per transaction
|
|
// (owner decision — the shared maxAdminGiftCardTransactionPence from
|
|
// giftcard_limits.go, the single source of the £250 cap). Both the create
|
|
// and topup branches fund the card from req.Amount and flow through this
|
|
// single validation point, so one guard covers both.
|
|
if req.ItemType == "gift_card" && amountPence > maxAdminGiftCardTransactionPence {
|
|
http.Error(w, "Gift card amount exceeds maximum (£250)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := ValidateAmount(amountPence); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, err.Error(), 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
|
|
}
|
|
|
|
if err := ValidateVerificationToken(req.VerificationToken); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// C1: when the client supplies no idempotency key, compute a DETERMINISTIC
|
|
// base key from the canonical request fields instead of a fresh
|
|
// uniqueChargeKey. A lost-response retry re-derives the SAME base key (the
|
|
// card nonce is deliberately excluded — it changes between retries), so the
|
|
// idempotency lookup below reuses the pending till_sale row and Square
|
|
// dedups on the key: ONE charge, ONE gift-card funding instead of the old
|
|
// random fallback's second charge + second funding. The base key is also the
|
|
// advisory-lock key, so concurrent same-sale retries serialize on it; the
|
|
// FINAL key is resolved by a slot scan under the lock (F5) so two genuinely
|
|
// DISTINCT keyless sales (same admin, same amount, no user / gift card)
|
|
// diverge onto different keys instead of the second being swallowed by the
|
|
// first's dedup.
|
|
var derivedBaseKey string
|
|
if req.IdempotencyKey == "" {
|
|
switch req.PaymentMethod {
|
|
case "saved_card", "online_square", "card_machine":
|
|
// Square-charging methods: deterministic base so a lost-response retry
|
|
// re-derives the SAME base and reuses the pending row — ONE charge,
|
|
// ONE gift-card funding instead of a second charge. The slot scan
|
|
// under the lock finalizes the candidate (see
|
|
// scanTillIdempotencyKeySlot).
|
|
derivedBaseKey = deriveTillIdempotencyKey(req, adminID)
|
|
req.IdempotencyKey = derivedBaseKey
|
|
default:
|
|
// cash / on_the_house: no Square charge, so a unique key per request is
|
|
// required — two identical keyless cash gift-card creations are
|
|
// distinct sales and must both succeed (see
|
|
// TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed).
|
|
req.IdempotencyKey = uniqueChargeKey("till-")
|
|
}
|
|
}
|
|
|
|
// Serialize till-sale attempts on the idempotency key to prevent concurrent
|
|
// same-key requests from both passing the idempotency check, both funding
|
|
// the gift card, and one dying on the till_sales idempotency_key UNIQUE
|
|
// constraint after the funding already committed. Mirrors the gift-card
|
|
// advisory-lock pattern (giftcards.go). Lock is keyed on the idempotency
|
|
// key so distinct sales are unaffected; a missing client key falls back to
|
|
// the DETERMINISTIC derived key above, so a retry of the same logical sale
|
|
// serializes on the SAME lock as the original attempt. Bounded try-lock
|
|
// (R6) so a contended lock never blocks the pool across the Square
|
|
// round-trip.
|
|
lockKey := req.IdempotencyKey
|
|
pinConn, err := db.Conn.Acquire(ctx)
|
|
if err != nil {
|
|
log.Printf("Failed to acquire connection for till-sale lock: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer pinConn.Release()
|
|
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:till:"+lockKey)
|
|
if err != nil {
|
|
log.Printf("Failed to acquire till-sale serialization lock for %s: %v", lockKey, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !lockOK {
|
|
log.Printf("Till-sale serialization lock for %s not acquired within bound — a sale is already in progress", lockKey)
|
|
http.Error(w, "Sale in progress, try again", http.StatusConflict)
|
|
return
|
|
}
|
|
defer releasePaymentLock(pinConn, "crussell:till:"+lockKey)
|
|
|
|
// F5: under the advisory lock, resolve the final deterministic key for a
|
|
// keyless Square-method sale. The slot scan advances past COMPLETED/FAILED
|
|
// sales occupying a candidate key, so two identical keyless walk-in creates
|
|
// never collapse onto one key; a PENDING sale never occupies a slot, so a
|
|
// lost-response retry re-derives the same candidate and the idempotency
|
|
// lookup below reuses the pending row. Running under the lock makes the
|
|
// scan-and-insert atomic for concurrent identical creates.
|
|
if derivedBaseKey != "" {
|
|
candidate, scanErr := scanTillIdempotencyKeySlot(ctx, derivedBaseKey)
|
|
if scanErr != nil {
|
|
log.Printf("Failed to resolve till-sale idempotency key for %s: %v", derivedBaseKey, scanErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.IdempotencyKey = candidate
|
|
}
|
|
|
|
// 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
|
|
var existingPendingMethod string
|
|
if req.IdempotencyKey != "" {
|
|
var existingID, existingStatus, existingItemID, existingStoredKey string
|
|
var existingTotal float64
|
|
var existingItemType, existingPaymentMethod string
|
|
err := db.Conn.QueryRow(ctx, `SELECT id, status, item_id, total_amount, item_type, payment_method, idempotency_key FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus, &existingItemID, &existingTotal, &existingItemType, &existingPaymentMethod, &existingStoredKey)
|
|
if err == nil {
|
|
if existingStatus == "completed" {
|
|
// C4-class: a same-key retry of a COMPLETED sale must report the
|
|
// STORED sale (amount, item type, method), never the freshly
|
|
// requested fields — echoing req.Amount on a different-amount
|
|
// retry misleads the till into believing the new amount was
|
|
// sold. Guard the amount exactly as the pending path below does:
|
|
// a different amount is a genuinely different sale, not a retry.
|
|
if int64(math.Round(existingTotal*100)) != int64(math.Round(req.Amount*100)) {
|
|
log.Printf("Till-sale dedup amount mismatch: completed record %s has %.2f, request has %.2f", existingID, existingTotal, req.Amount)
|
|
http.Error(w, "Amount does not match the completed till sale", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// RE-VALIDATE the matched sale's refund state before reporting
|
|
// it as success (same guard as the booking/tip/gift-card/
|
|
// terminal completed-dedups): a refunded sale's money is no
|
|
// longer live, so a same-key retry must not report it as
|
|
// success.
|
|
if refunded, rErr := tillSaleHasLiveRefund(ctx, db.Conn, existingID); rErr != nil {
|
|
log.Printf("Failed to re-validate till-sale dedup hit %s against refunds: %v", existingID, rErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
} else if refunded {
|
|
log.Printf("Till-sale retry rejected: completed sale %s (key %q) was refunded — refusing to report a refunded sale as success", existingID, req.IdempotencyKey)
|
|
http.Error(w, "This till sale has been refunded and can no longer be replayed", http.StatusConflict)
|
|
return
|
|
}
|
|
if err := json.NewEncoder(w).Encode(TillSaleResponse{
|
|
ID: existingID,
|
|
ItemType: existingItemType,
|
|
TotalAmount: existingTotal,
|
|
PaymentMethod: existingPaymentMethod,
|
|
Status: "completed",
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if existingStatus == "pending" {
|
|
// Guard the amount: a retry with a different amount must not
|
|
// reuse the pending sale — the gift card was already funded at
|
|
// the old amount, so charging the new amount to Square would
|
|
// leave the card funded at the wrong value.
|
|
if int64(math.Round(existingTotal*100)) != int64(math.Round(req.Amount*100)) {
|
|
log.Printf("Till-sale retry amount mismatch: pending record %s has %.2f, request has %.2f", existingID, existingTotal, req.Amount)
|
|
http.Error(w, "Amount does not match the pending till sale", http.StatusBadRequest)
|
|
return
|
|
}
|
|
existingPendingID = existingID
|
|
existingPendingGiftCard = existingItemID
|
|
existingPendingMethod = existingPaymentMethod
|
|
// F5: reuse the row's STORED idempotency key rather than a
|
|
// re-derived one — the stored key is what Square charged (and
|
|
// dedups on), so a retry must replay it verbatim.
|
|
if existingStoredKey != "" {
|
|
req.IdempotencyKey = existingStoredKey
|
|
}
|
|
}
|
|
if existingStatus == "failed" {
|
|
// Swept as stale (>24h) or definitively rejected — a retry would
|
|
// risk a second Square charge. Reject cleanly (R2).
|
|
log.Printf("Till-sale retry rejected: record %s was marked failed", existingID)
|
|
http.Error(w, "This till sale previously failed and can no longer be retried", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Defense-in-depth fallback: resolve a pending TOP-UP by the gift card
|
|
// itself, not just the idempotency key. A lost-response retry carrying a
|
|
// fresh/absent key (an old frontend, or a key regenerated for a changed
|
|
// cart) must still reuse the pending till_sale row and adopt its STORED
|
|
// idempotency key — otherwise the retry would issue a second Square charge
|
|
// and fund the card twice. Only the row's own key may be charged: adopting
|
|
// it makes Square dedup the retry against the original charge. An amount
|
|
// mismatch proves this is a genuinely different sale (same card, new
|
|
// amount), so the pending row is left untouched and the request proceeds
|
|
// as a new charge below. 'create' cannot be resolved this way (the request
|
|
// carries no gift-card id — the card is created by the first attempt), so
|
|
// create retries rely on the client-supplied key, which the till frontend
|
|
// caches per cart line.
|
|
if existingPendingID == "" && req.Action == "topup" && req.GiftCardID != nil && *req.GiftCardID != "" {
|
|
cardID := validators.NormalizeGiftCardCode(*req.GiftCardID)
|
|
var existingID, existingItemID, existingKey string
|
|
var existingTotal float64
|
|
err := db.Conn.QueryRow(ctx, `
|
|
SELECT id, item_id, total_amount, idempotency_key
|
|
FROM till_sales
|
|
WHERE item_id = $1 AND status = 'pending'
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`, cardID).Scan(&existingID, &existingItemID, &existingTotal, &existingKey)
|
|
if err == nil && existingKey != "" {
|
|
if int64(math.Round(existingTotal*100)) == int64(math.Round(req.Amount*100)) {
|
|
log.Printf("Till-sale retry resolved by gift card %s: reusing pending sale %s with stored idempotency key", cardID, existingID)
|
|
existingPendingID = existingID
|
|
existingPendingGiftCard = existingItemID
|
|
req.IdempotencyKey = existingKey
|
|
} else {
|
|
log.Printf("Till-sale retry by gift card %s skipped: pending sale %s has %.2f, request has %.2f — treated as a new sale", cardID, existingID, existingTotal, req.Amount)
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIX 2 (F6): the sweep serializes its fail/claw-back of a stale pending
|
|
// till_sale on "crussell:till:<STORED idempotency_key>"
|
|
// (acquireTillSaleSweepLock, sweep.go) — but the advisory lock acquired
|
|
// above is keyed on the REQUEST's base key. A keyless sale's slot scan can
|
|
// advance the final stored key past the base (e.g. "till-<hash>-1" when a
|
|
// COMPLETED/FAILED sale occupies the base slot), and the pending-reuse
|
|
// resolution above can adopt a row's STORED key that differs from the
|
|
// request key. When they diverge, a retry would hold the base lock while
|
|
// the sweep holds the stored-key lock — two DIFFERENT locks that do not
|
|
// serialize — so the sweep could fail the sale + claw back the funded gift
|
|
// card while this retry's Square charge is mid-flight: customer charged AND
|
|
// funding clawed back. req.IdempotencyKey at this point IS the key the
|
|
// till_sales INSERT below stores (the FINAL key), so it must ALSO be locked
|
|
// before the money-critical section.
|
|
//
|
|
// The base lock is deliberately kept held: it makes the slot scan-and-insert
|
|
// atomic against a concurrent identical keyless create (releasing it right
|
|
// after the scan would let the second sale derive the same free slot, insert
|
|
// a duplicate and die on the idempotency_key UNIQUE constraint). The final
|
|
// lock is acquired in the SAME "crussell:till:" family on the SAME pinned
|
|
// conn, in the consistent order base → final. The sweep holds only the ONE
|
|
// stored-key lock and never acquires a base key, and no other code path
|
|
// acquires "crussell:till:" keys in reverse order, so no lock-ordering cycle
|
|
// is possible. When the final key equals the base key (no suffix, no
|
|
// adoption), the base lock already IS the final lock and nothing more is
|
|
// needed. A bounded try-lock matches the base lock's pool-exhaustion
|
|
// defence: if the sweep holds the stored key across its fail/claw-back we
|
|
// surface 409 "try again" instead of pinning a pool conn for its duration.
|
|
finalLockKey := req.IdempotencyKey
|
|
if finalLockKey != lockKey {
|
|
finalLockOK, fErr := acquireAdvisoryLock(ctx, pinConn, "crussell:till:"+finalLockKey)
|
|
if fErr != nil {
|
|
log.Printf("Failed to acquire till-sale final-key serialization lock for %s: %v", finalLockKey, fErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !finalLockOK {
|
|
log.Printf("Till-sale final-key serialization lock for %s not acquired within bound — a sweep or same-key retry is in progress", finalLockKey)
|
|
http.Error(w, "Sale in progress, try again", http.StatusConflict)
|
|
return
|
|
}
|
|
defer releasePaymentLock(pinConn, "crussell:till:"+finalLockKey)
|
|
log.Printf("Till-sale final key %s differs from base key %s — holding both till locks across the Square round-trip so the sweep's stored-key lock serializes", finalLockKey, lockKey)
|
|
}
|
|
|
|
service := NewPaymentService()
|
|
|
|
// MEDIUM-5: the shared £5,000/day admin gift-card cap (giftcard_limits.go
|
|
// maxAdminGiftCardDailyPence) must cover till creates/topups too — the till
|
|
// is an admin money surface and otherwise could issue unlimited balance.
|
|
// adminGiftCardValueToday now includes the till's own same-day value
|
|
// (double-count-free), so this is the SAME daily-cap check as
|
|
// CreateGiftCard / TopUpGiftCard / TransferGiftCard. Placed AFTER the
|
|
// idempotency dedup so a same-key retry of an already-completed sale is
|
|
// returned (not blocked) even on a capped day. Runs before any gift-card
|
|
// write.
|
|
//
|
|
// F5: the cap check and the sale's gift-card creation/top-up run under the
|
|
// SAME per-admin advisory lock the admin API surfaces use
|
|
// (acquireGiftCardDailyCapLock, giftcards.go). The per-sale
|
|
// crussell:till:<idempotencyKey> lock above serializes ONE sale's retries,
|
|
// but two concurrent DISTINCT till sales by the same admin could otherwise
|
|
// both read a below-cap day's value before either commits and overshoot the
|
|
// £5,000 ceiling. The lock is acquired BEFORE the adminGiftCardValueToday
|
|
// read and held (via the defers) through the transaction that records the
|
|
// new value, exactly like CreateGiftCard / TopUpGiftCard /
|
|
// TransferGiftCard.
|
|
capPinConn, capLockOK := acquireGiftCardDailyCapLock(ctx, w, adminID)
|
|
if !capLockOK {
|
|
return
|
|
}
|
|
defer capPinConn.Release()
|
|
defer releasePaymentLock(capPinConn, giftCardDailyCapLockKey+adminID)
|
|
|
|
adminValueToday, dailyErr := adminGiftCardValueToday(ctx, db.Conn, adminID)
|
|
if dailyErr != nil {
|
|
log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, dailyErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if int64(math.Round(adminValueToday*100))+amountPence > maxAdminGiftCardDailyPence {
|
|
http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// squarePaymentID/squareCheckoutID are set inside the payment-method switch
|
|
// below but must be declared before the deferred cancel-on-error closure
|
|
// (registered at tx creation) so it can read the live checkout id.
|
|
var squarePaymentID *string
|
|
var squareCheckoutID *string
|
|
|
|
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
|
|
}
|
|
// Cancel-on-error for an orphaned live checkout: a card_machine checkout
|
|
// created at Square inside this tx is cancelled if the request fails before
|
|
// commit (e.g. the till_sales INSERT dies on the idempotency_key UNIQUE
|
|
// constraint) — otherwise the checkout stays live at the terminal as an
|
|
// invisible, untracked charge. The pending-retry reuse branch never sets
|
|
// checkoutCreated (it reuses a live checkout and must never cancel it),
|
|
// and committed=true after a successful commit means a committed sale's
|
|
// checkout is never cancelled here.
|
|
var checkoutCreated bool
|
|
committed := false
|
|
defer func() {
|
|
if !committed && checkoutCreated && squareCheckoutID != nil {
|
|
if cErr := SquareClient.CancelCheckout(context.Background(), *squareCheckoutID); cErr != nil {
|
|
log.Printf("CRITICAL: till checkout %s created at Square but request failed pre-commit; cancel failed: %v — MANUAL RECONCILIATION REQUIRED", *squareCheckoutID, cErr)
|
|
}
|
|
}
|
|
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 {
|
|
expiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, tx)
|
|
if expiryErr != nil {
|
|
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
|
expiryMonths = defaultGiftCardExpiryMonths
|
|
}
|
|
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"
|
|
}
|
|
// HMRC VAT Notice 700/7: salon-only gift cards are SPV by
|
|
// definition, so the EFFECTIVE type is written even when the
|
|
// stored setting is 'MPV' — recording raw 'MPV' here would make
|
|
// the redemption path defer VAT a second time (VAT is already
|
|
// collected at sale via the GetVATConfig SPV override).
|
|
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
|
|
VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
|
|
RETURNING id
|
|
`, req.Amount, adminID, purchaseVoucherType, expiryMonths).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(),
|
|
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
|
WHERE id = $2
|
|
`, *req.RedeemToUserID, giftCardID, expiryMonths)
|
|
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
|
|
var expiryDate sql.NullTime
|
|
err = tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&isInventory, &previousTotal, &expiryDate)
|
|
if err != nil {
|
|
log.Printf("Failed to check gift card state: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// money-F4: an expired gift card must never be topped up — the
|
|
// UPDATE below resets expiry_date to NOW()+months and would
|
|
// resurrect a card whose remaining value the nightly cleanup job
|
|
// already forfeited. Mirrors giftcards.go RedeemGiftCard's expiry
|
|
// gate (same DB-clock comparison, same 400); a NULL expiry_date
|
|
// (legacy) is treated as unexpired.
|
|
expired, err := giftCardExpired(ctx, tx, expiryDate)
|
|
if err != nil {
|
|
log.Printf("Failed to check gift card expiry: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if expired {
|
|
http.Error(w, "Gift card has expired", http.StatusBadRequest)
|
|
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(),
|
|
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
|
WHERE id = $2
|
|
`, req.Amount, cardID, expiryMonths)
|
|
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 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
|
|
var savedCardCustomerID string
|
|
// tillSquareSourceID records the exact source_id sent to CreatePayment
|
|
// (the ccof: card id or the cnon: nonce) so the pending row stores it for
|
|
// the sweep's identical-body replay.
|
|
var tillSquareSourceID 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, ''), payment_method FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID, &existingPendingMethod)
|
|
if err != nil {
|
|
log.Printf("Failed to query existing pending sale checkout: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Method-switch guard on pending-retry (F1): switching the payment method of
|
|
// a pending sale whose Square charge outcome is unknown must never mint a
|
|
// NEW checkout or charge that cannot dedup against the original. A pending
|
|
// card_machine checkout (whether its id is still stored, or was lost with
|
|
// the lost response) may still be live at the terminal, and a pending
|
|
// online_square/saved_card charge may have landed — retrying either as a
|
|
// different method risks TWO charges for one card. Reject the switch
|
|
// outright: the sale must be completed or refunded first. online_square ↔
|
|
// saved_card retries are the one allowed cross-method case: both charge via
|
|
// Square CreatePayment under the SAME idempotency key, so Square dedups the
|
|
// retry onto the original charge (no second charge).
|
|
// NOTE: a future provisional "tmp-" till_sales row (pre-Square, as the
|
|
// booking path uses) must be treated as "no real checkout" by BOTH this
|
|
// reuse and the method-switch guard — a "tmp-" id is provably not live at
|
|
// Square.
|
|
if existingPendingID != "" {
|
|
cardMachineInProgress := existingPendingCheckoutID != "" || existingPendingMethod == "in_person_card"
|
|
if cardMachineInProgress && req.PaymentMethod != "card_machine" {
|
|
// Pending card-machine sale retried by any other method: the original
|
|
// terminal checkout may still complete — switching would let the
|
|
// customer be charged at the terminal AND by the new method. The
|
|
// terminal checkout cannot be cancelled via this API.
|
|
log.Printf("Till-sale retry rejected: pending sale %s is tied to a card-machine checkout, cannot switch method to %s", existingPendingID, req.PaymentMethod)
|
|
http.Error(w, "This pending sale is tied to a card-machine checkout — complete or refund it first, then retry with card machine payment", http.StatusConflict)
|
|
return
|
|
}
|
|
if req.PaymentMethod == "card_machine" && !cardMachineInProgress {
|
|
// F1: a pending online_square/saved_card charge (outcome unknown)
|
|
// retried as card_machine would mint a NEW terminal checkout at
|
|
// Square — CreateCheckout cannot dedup against the original
|
|
// CreatePayment charge, so the original may land on top of the new
|
|
// terminal charge (double charge for one card).
|
|
log.Printf("Till-sale retry rejected: pending sale %s has payment method %s, cannot retry as card_machine (original charge outcome unknown)", existingPendingID, existingPendingMethod)
|
|
http.Error(w, "This pending sale is already in progress on a different payment method — complete or refund it first", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Reconcile-or-reject on a cash/on_the_house retry of a pending card sale.
|
|
// The gift card was funded by a Square charge whose outcome is unknown; a
|
|
// definitive answer from Square decides whether cash may be taken. This
|
|
// MUST run before the method switch below so a lost-response sale (no
|
|
// square_payment_id) forces the original card-method retry instead of
|
|
// taking cash on top of a charge that may have landed.
|
|
if existingPendingID != "" && (req.PaymentMethod == "cash" || req.PaymentMethod == "on_the_house") {
|
|
var sqPaymentID string
|
|
if err := tx.QueryRow(ctx, `SELECT COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1`,
|
|
existingPendingID).Scan(&sqPaymentID); err != nil {
|
|
log.Printf("Failed to query pending sale %s square_payment_id: %v", existingPendingID, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if sqPaymentID == "" {
|
|
// Lost response: the charge may have landed at Square and cannot
|
|
// be looked up. Taking cash risks a double payment. Force the
|
|
// original card-method retry — Square dedups on the same
|
|
// idempotency key and resolves the lost response.
|
|
http.Error(w, "Original card charge outcome is unknown — retry with the original card method", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
pr, gErr := SquareClient.GetPayment(ctx, sqPaymentID)
|
|
switch {
|
|
case gErr == nil && pr.Status == "COMPLETED":
|
|
// Money landed. Rescue the sale, refuse the cash.
|
|
if _, upErr := db.Conn.Exec(ctx, `UPDATE till_sales SET status='completed', updated_at=NOW() WHERE id=$1 AND status='pending'`, existingPendingID); upErr != nil {
|
|
log.Printf("CRITICAL: card payment %s for pending till sale %s is COMPLETED but the rescue UPDATE failed: %v — MANUAL RECONCILIATION REQUIRED", sqPaymentID, existingPendingID, upErr)
|
|
}
|
|
http.Error(w, "This sale was already paid by card — do not take cash", http.StatusConflict)
|
|
return
|
|
case squarePaymentErrorIsNotFound(gErr), gErr == nil && (pr.Status == "FAILED" || pr.Status == "CANCELED"):
|
|
// Provably no money landed — cash is safe; fall through.
|
|
case gErr != nil:
|
|
http.Error(w, "Unable to confirm the card payment status — try again", http.StatusServiceUnavailable)
|
|
return
|
|
default:
|
|
http.Error(w, "Card charge status not definitively failed — do not take cash", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
|
|
// cardUserID is the owner of the charged saved card (till sales are not
|
|
// user-scoped). Resolved in the saved_card case below; hoisted here because
|
|
// the post-charge 2FA consumption + audit after the Square call need it.
|
|
var cardUserID sql.NullString
|
|
|
|
// SCA verification token (if any) — hoisted so the 2FA gate in the
|
|
// saved_card case (a present token skips the gate: SCA-primary) and the
|
|
// CreatePaymentReq after the switch share one extraction.
|
|
tillVerificationToken := ""
|
|
if req.VerificationToken != nil {
|
|
tillVerificationToken = *req.VerificationToken
|
|
}
|
|
|
|
switch req.PaymentMethod {
|
|
case "cash":
|
|
saleStatus = "completed"
|
|
dbPaymentMethod = "cash"
|
|
case "saved_card":
|
|
dbPaymentMethod = "online_square"
|
|
|
|
// The till path is not user-scoped, so fetch the card's owner along with
|
|
// the charge details — the owner-agnostic SELECT is the source of truth
|
|
// for ownership. The owner is needed to lazily provision a Square
|
|
// customer if the row predates P14 (R6), and to key the 2FA/consent
|
|
// gate. cardUserID is declared at function scope (before the switch)
|
|
// because the post-charge 2FA consumption + audit need it after the
|
|
// Square call.
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT user_id, COALESCE(square_card_id, ''), COALESCE(square_customer_id, '')
|
|
FROM user_saved_cards
|
|
WHERE id = $1 AND deleted_at IS NULL
|
|
`, *req.UserSavedCardID).Scan(&cardUserID, &savedCardSqCardID, &savedCardCustomerID)
|
|
if err != nil {
|
|
log.Printf("Failed to get saved card details: %v", err)
|
|
http.Error(w, "Card not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// F1 ownership invariant: every other charge surface verifies the card
|
|
// belongs to the request's user before charging. When the admin names
|
|
// the customer being served (req.UserID), the card MUST be owned by
|
|
// that customer — an ownerless or foreign card is a confused-deputy
|
|
// signal and is rejected outright. An OMITTED user_id is the admin-till
|
|
// legit use (the admin may charge any customer's card): the charge
|
|
// proceeds under the RESOLVED owner, which keys the 2FA/consent gate
|
|
// below and is the target of the confused-deputy CRITICAL audit on
|
|
// success.
|
|
if req.UserID != nil && *req.UserID != "" && (!cardUserID.Valid || cardUserID.String != *req.UserID) {
|
|
http.Error(w, "Saved card does not belong to the specified user", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// A ccof: source can NEVER be charged without a CustomerID — Square
|
|
// rejects the payment. Legacy pre-P14 rows have an empty
|
|
// square_customer_id; provision + persist for the card's owner BEFORE
|
|
// charging (R6).
|
|
if savedCardCustomerID == "" {
|
|
if !cardUserID.Valid {
|
|
http.Error(w, "Saved card has no owner and cannot be charged", http.StatusBadRequest)
|
|
return
|
|
}
|
|
provisioned, provErr := service.EnsureSquareCustomer(ctx, cardUserID.String)
|
|
if provErr != nil {
|
|
log.Printf("Failed to provision Square customer for saved card %s (user %s): %v", *req.UserSavedCardID, cardUserID.String, provErr)
|
|
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
savedCardCustomerID = provisioned
|
|
if _, upErr := db.Conn.Exec(ctx, `
|
|
UPDATE user_saved_cards SET square_customer_id = $1
|
|
WHERE id = $2
|
|
`, provisioned, *req.UserSavedCardID); upErr != nil {
|
|
log.Printf("Failed to persist Square customer id on saved card %s (non-fatal): %v", *req.UserSavedCardID, upErr)
|
|
}
|
|
}
|
|
|
|
// 2FA gating (C5): charging a customer's saved card requires 2FA when
|
|
// the feature is enforced. A charge carrying a Square verification_token
|
|
// (SCA performed) skips the gate; a token-less charge is refused 402
|
|
// verification_required (SCA-only — the homegrown 2FA fallback was
|
|
// removed). An SCA tokenize-result token (new_card_token) also skips the
|
|
// gate: the token only exists after the issuer completed buyer
|
|
// verification for this card + amount (SCA-primary), mirroring the
|
|
// booking path's scaTokenizedSavedCard skip.
|
|
scaTokenizedSavedCard := req.NewCardToken != nil && *req.NewCardToken != ""
|
|
if cardUserID.Valid && !scaTokenizedSavedCard {
|
|
if gateOK, _ := requireTwoFactorForCardAccess(w, r, service, cardUserID.String, tillVerificationToken, existingPendingID == ""); !gateOK {
|
|
return
|
|
}
|
|
}
|
|
|
|
// SCA tokenize-result wire contract (mirrors resolveChargeSource's
|
|
// saved-card branch): when the request carries new_card_token, the
|
|
// token is the one-time charge source and the saved-card row supplies
|
|
// the customer; otherwise the stored ccof: card id is the source.
|
|
tillSquareSourceID = savedCardSqCardID
|
|
if req.NewCardToken != nil && *req.NewCardToken != "" {
|
|
tillSquareSourceID = *req.NewCardToken
|
|
}
|
|
saleStatus = "pending"
|
|
needsSquarePayment = true
|
|
case "card_machine":
|
|
dbPaymentMethod = "in_person_card"
|
|
|
|
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 {
|
|
// F1: never mint a NEW terminal checkout while a pending charge for
|
|
// the same logical sale exists. existingPendingID (resolved by the
|
|
// idempotency-key lookup or the gift-card fallback) IS such a
|
|
// pending charge — a pending card_machine sale with no stored
|
|
// checkout id means the prior response was lost and the original
|
|
// checkout may still be live at the terminal; a fresh checkout
|
|
// would orphan it into a second, untracked charge.
|
|
if existingPendingID != "" {
|
|
log.Printf("Till-sale retry rejected: pending sale %s has no stored checkout id; refusing to create a second terminal checkout (original may still be live)", existingPendingID)
|
|
http.Error(w, "This pending sale has a card-machine payment in progress with an unknown checkout — complete or refund it first", http.StatusConflict)
|
|
return
|
|
}
|
|
// Defense-in-depth: a card_machine top-up on a card that already
|
|
// carries a pending till_sale (reached only when the earlier
|
|
// idempotency / gift-card resolution treated this as a NEW sale,
|
|
// e.g. a different amount) must not mint a terminal checkout while
|
|
// that pending sale's charge is still unresolved.
|
|
if req.Action == "topup" && req.GiftCardID != nil && *req.GiftCardID != "" {
|
|
var pendingOnCard string
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id FROM till_sales
|
|
WHERE item_id = $1 AND status = 'pending'
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`, validators.NormalizeGiftCardCode(*req.GiftCardID)).Scan(&pendingOnCard); err == nil && pendingOnCard != "" {
|
|
log.Printf("Till-sale card_machine create rejected: gift card %s already has a pending till sale %s", *req.GiftCardID, pendingOnCard)
|
|
http.Error(w, "This gift card already has a pending sale — complete or refund it first", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
checkoutReq := square.CreateCheckoutReq{
|
|
Amount: penceAmount,
|
|
Currency: "GBP",
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
ReferenceID: giftCardID,
|
|
AllowTipping: 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
|
|
checkoutCreated = true
|
|
saleStatus = "pending"
|
|
}
|
|
case "online_square":
|
|
dbPaymentMethod = "online_square"
|
|
|
|
tillSquareSourceID = req.CardToken
|
|
saleStatus = "pending"
|
|
needsSquarePayment = true
|
|
case "on_the_house":
|
|
saleStatus = "completed"
|
|
dbPaymentMethod = "on_the_house"
|
|
}
|
|
|
|
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. Refresh
|
|
// the stored source: this attempt may charge a different token than the
|
|
// failed attempt (one-time cnon: nonces are spent), and the sweep
|
|
// replays the charge from the stored source.
|
|
tillSaleID = existingPendingID
|
|
if tillSquareSourceID != "" {
|
|
if _, srcErr := tx.Exec(ctx, `UPDATE till_sales SET square_source_id = $1 WHERE id = $2`, tillSquareSourceID, tillSaleID); srcErr != nil {
|
|
log.Printf("Failed to update square_source_id on reused till sale %s: %v", tillSaleID, srcErr)
|
|
}
|
|
// B6: the sweep replays the stored square_request_snapshot VERBATIM,
|
|
// so the snapshot's source_id must stay in lock-step with the
|
|
// refreshed square_source_id — a snapshot whose source differs from
|
|
// the row's source returns IDEMPOTENCY_KEY_REUSED at Square and
|
|
// strands the row pending forever. Refresh both columns in this same
|
|
// transaction so they never diverge.
|
|
refreshTillSnapshotSource(ctx, tx, tillSaleID, tillSquareSourceID)
|
|
}
|
|
} 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,
|
|
square_source_id
|
|
) VALUES ($1, $2, $3, 1, $4, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW(), $14)
|
|
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,
|
|
tillSquareSourceID,
|
|
).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) {
|
|
applyVATToChargeRecord(ctx, tx, tillSaleID, true)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
committed = true
|
|
|
|
// 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: tillSquareSourceID,
|
|
CustomerID: savedCardCustomerID,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
Note: "Gift Card " + req.Action,
|
|
BuyerEmail: buyerEmail,
|
|
// MIT (merchant-initiated): the admin till charging a
|
|
// customer's saved card is merchant-initiated, not
|
|
// cardholder-initiated. customer_initiated=false classifies it
|
|
// MIT for Square: no SCA is demanded and no liability shift
|
|
// applies — correct for an operator-initiated gift-card top-up.
|
|
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: false},
|
|
// Forward the 3DS/SCA verification token when the request
|
|
// carries one (the online_square branch already did; the
|
|
// saved-card branch did not).
|
|
VerificationToken: tillVerificationToken,
|
|
}
|
|
// M1: store the verbatim request JSON so the sweep can replay the
|
|
// charge with an IDENTICAL body under the same key — Square
|
|
// compares the whole request on key reuse, and a reconstructed body
|
|
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
|
// The snapshot holds PII (buyer email + ccof token), so it is
|
|
// encrypted at rest via encryptSnapshot (plaintext in dev/mock).
|
|
//
|
|
// The write is INTENTIONALLY unconditional
|
|
// (writeChargeSnapshotUnconditional — Loop A regression check 4a
|
|
// restored it): the pending-reuse branch above already refreshed
|
|
// square_request_snapshot in the SAME transaction as the
|
|
// square_source_id refresh (refreshTillSnapshotSource, B6), and this
|
|
// post-commit write stores the fresh full body for THIS attempt.
|
|
// The immutability guard would wrongly skip this write on the reuse
|
|
// path when the in-tx refresh failed best-effort.
|
|
writeChargeSnapshotUnconditional(ctx, db.Conn, "till_sales", tillSaleID, paymentReq, "till sale")
|
|
|
|
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).
|
|
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
|
|
}
|
|
// Charge the cnon: nonce DIRECTLY (R6). The old code tokenized the
|
|
// till card via CreateCardOnFile under a synthetic "till-<giftCardID>"
|
|
// reference and charged the resulting ccof: id — but that card was
|
|
// never saved or re-listed anywhere, so the CreateCardOnFile call was
|
|
// pure overhead AND would have charged a card-on-file source without a
|
|
// customer (Square rejects ccof without CustomerID). A cnon: nonce
|
|
// needs neither card-on-file nor customer.
|
|
paymentReq := square.CreatePaymentReq{
|
|
Amount: penceAmount,
|
|
Currency: "GBP",
|
|
SourceID: req.CardToken,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
Note: "Gift Card " + req.Action,
|
|
BuyerEmail: buyerEmail,
|
|
VerificationToken: tillVerificationToken,
|
|
}
|
|
// M1: store the verbatim request JSON so the sweep can replay the
|
|
// charge with an IDENTICAL body under the same key — Square
|
|
// compares the whole request on key reuse, and a reconstructed body
|
|
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
|
// The write is INTENTIONALLY unconditional
|
|
// (writeChargeSnapshotUnconditional — Loop A regression check 4a
|
|
// restored it): the pending-reuse branch above refreshes
|
|
// square_request_snapshot in the SAME transaction as the
|
|
// square_source_id refresh (refreshTillSnapshotSource, B6), and this
|
|
// post-commit write stores the FRESH full body for THIS attempt. The
|
|
// immutability guard would wrongly skip this write on the reuse path
|
|
// when the in-tx refresh failed best-effort.
|
|
writeChargeSnapshotUnconditional(ctx, db.Conn, "till_sales", tillSaleID, paymentReq, "till sale")
|
|
|
|
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
|
}
|
|
|
|
if squareErr != nil {
|
|
log.Printf("Failed to process payment: %v", squareErr)
|
|
// The gift card was already created/top-upped in the committed DB
|
|
// transaction before this Square charge. On a DEFINITIVE rejection
|
|
// (declined/expired) the customer was never charged, so the funded
|
|
// card must be clawed back — otherwise it stays funded forever (the
|
|
// stale-pending sweep only marks the sale failed, it never reverts
|
|
// the card). Ambiguous failures (network/5xx) leave the sale pending
|
|
// so a late retry can still complete it — the card must stay funded.
|
|
// The clawback also runs on a pending-retry: the card was funded by
|
|
// a PRIOR request of this same sale (same idempotency key), and the
|
|
// retry's definitive failure proves this sale's charge can never
|
|
// complete — reverting the funding is required, not "the prior
|
|
// attempt's responsibility".
|
|
if isDefinitiveChargeFailure(squareErr) {
|
|
if revErr := revertGiftCardFunding(ctx, req.Action, giftCardID, req.Amount, req.RedeemToUserID, tillSaleID); revErr != nil {
|
|
// M6: the clawback ALWAYS reverts the funding on a failed
|
|
// sale; the two failure shapes here are a partial reversal
|
|
// (funding was already spent — a CRITICAL admin notification
|
|
// is already inserted by the clawback) and a hard failure.
|
|
if errors.Is(revErr, errClawbackPartiallyReversed) {
|
|
log.Printf("CRITICAL: till sale %s charge definitively failed (%v) — gift-card funding was PARTIALLY clawed back (already spent) — MANUAL RECONCILIATION REQUIRED: gift card %s may retain a residual to reconcile", tillSaleID, squareErr, giftCardID)
|
|
} else {
|
|
log.Printf("CRITICAL: till sale %s charge definitively failed (%v) but gift-card clawback also failed: %v — MANUAL RECONCILIATION REQUIRED: gift card %s may still be funded", tillSaleID, squareErr, revErr, giftCardID)
|
|
}
|
|
}
|
|
}
|
|
// 402 only for definitive declines; ambiguous transport/5xx must be
|
|
// 503 so the pending sale stays resumable on a same-key retry
|
|
// (M3). The clawback decision above stays keyed on
|
|
// isDefinitiveChargeFailure, unchanged.
|
|
// SCA-required failures must surface the structured
|
|
// verification_required body so the frontend triggers the 3DS
|
|
// challenge, not a plain decline.
|
|
if isVerificationRequiredError(squareErr) {
|
|
writeVerificationRequiredResponse(w)
|
|
return
|
|
}
|
|
http.Error(w, "Payment failed", chargeFailureStatus(squareErr))
|
|
return
|
|
}
|
|
|
|
// Square succeeded — update the till_sale record. The status='pending'
|
|
// guard (claim-first) prevents resurrecting a sale the stale-pending
|
|
// sweep / gift-card clawback resolved to 'failed' while the Square
|
|
// charge was in flight: money was taken and the card was already
|
|
// clawed back, so the sale must stay failed (0 rows → CRITICAL below).
|
|
tillTag, upErr := db.Conn.Exec(ctx,
|
|
`UPDATE till_sales SET status = 'completed', square_payment_id = $1 WHERE id = $2 AND status = 'pending'`,
|
|
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
|
|
}
|
|
if tillTag.RowsAffected() == 0 {
|
|
// The stale-pending sweep (or a clawback) resolved the sale while
|
|
// the Square charge was in flight: the customer WAS charged, but
|
|
// the row no longer says 'pending' and a concurrent clawback may
|
|
// have reverted the funded gift card. Mirroring the non-Square
|
|
// pending-reuse path below, never report success for a row the DB
|
|
// doesn't agree on. FIX 1: this retry's charge just landed, so
|
|
// re-credit any clawed-back gift-card funding BEFORE failing
|
|
// loudly — the customer ends up "charged AND funded" instead of
|
|
// "charged AND clawed back".
|
|
if recreditTillSaleFundingAfterMidFlightResolve(ctx, req, tillSaleID, giftCardID, paymentResult) {
|
|
log.Printf("CRITICAL: Square payment %s succeeded but till_sale %s was already resolved (0 rows updated) — gift-card funding re-credited after the concurrent clawback; sale left failed — MANUAL RECONCILIATION REQUIRED to complete the sale record", paymentResult.SquarePayID, tillSaleID)
|
|
} else {
|
|
log.Printf("CRITICAL: Square payment %s succeeded but till_sale %s was already resolved (0 rows updated) — charge taken and gift-card funding NOT re-credited; MANUAL RECONCILIATION REQUIRED", paymentResult.SquarePayID, tillSaleID)
|
|
}
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// MEDIUM-2: a saved-card till charge reached its terminal SUCCESS state
|
|
// — consume the verified 2FA code now (the gate verified without
|
|
// consuming, so a failed/ambiguous charge did not burn the code and a
|
|
// same-key retry could reuse it). Best-effort after the completion
|
|
// write: a consume failure cannot undo the completed sale, it only
|
|
// leaves the code valid until its 10-minute expiry.
|
|
if req.PaymentMethod == "saved_card" && cardUserID.Valid {
|
|
if consErr := twofa.ConsumePendingCode(ctx, db.Conn, cardUserID.String); consErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s succeeded but consuming the 2FA code for user %s failed: %v — MANUAL RECONCILIATION REQUIRED", paymentResult.SquarePayID, cardUserID.String, consErr)
|
|
}
|
|
// MEDIUM-3a: record the admin-initiated saved-card till charge in
|
|
// admin_audit_log (mirroring giftcards.go's balance_check audit).
|
|
InsertAdminAuditCharge(ctx, adminID, cardUserID.String, "till_saved_card_charge", map[string]any{
|
|
"till_sale_id": tillSaleID,
|
|
"item_type": req.ItemType,
|
|
"gift_card_id": giftCardID,
|
|
"amount": req.Amount,
|
|
"card_last4": paymentResult.CardLast4,
|
|
"square_payment_id": paymentResult.SquarePayID,
|
|
})
|
|
// F1 confused-deputy audit: a saved-card till charge that proceeded
|
|
// WITHOUT the request naming the customer (user_id omitted) charged
|
|
// the card under its RESOLVED owner — a card never associated with
|
|
// the sale's stated user. Surface a CRITICAL operator notification
|
|
// (deduped per owner) so an admin who charges a card under an
|
|
// unintended owner is caught. A provided-and-matched user_id needs
|
|
// no such flag; a provided-and-mismatched one never reaches a charge.
|
|
if req.UserID == nil || *req.UserID == "" {
|
|
ownerID := cardUserID.String
|
|
insertCriticalPaymentNotification(ctx, nil, &ownerID)
|
|
}
|
|
}
|
|
|
|
saleStatus = "completed"
|
|
}
|
|
|
|
// Pending-reuse resolved by a non-Square method (cash / on_the_house): the
|
|
// original sale row was inserted as 'pending' (prior Square attempt failed
|
|
// or the method was switched after a failed charge). The reused row skips
|
|
// the INSERT, and no Square payment runs, so the status must be flipped to
|
|
// 'completed' explicitly — otherwise the row stays pending forever while
|
|
// the response claims success.
|
|
if existingPendingID != "" && !needsSquarePayment && req.PaymentMethod != "card_machine" {
|
|
tag, upErr := db.Conn.Exec(ctx,
|
|
`UPDATE till_sales SET status = 'completed', updated_at = NOW() WHERE id = $1 AND status = 'pending'`,
|
|
tillSaleID,
|
|
)
|
|
if upErr != nil {
|
|
log.Printf("Failed to complete pending till sale %s after non-Square payment: %v", tillSaleID, upErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
// The sweep failed (or a clawback reverted the gift card) between
|
|
// this retry's read and this write — the cash must not be taken.
|
|
log.Printf("Pending till sale %s was already resolved before the %s retry could complete it", tillSaleID, req.PaymentMethod)
|
|
http.Error(w, "This sale was already resolved — do not take cash", http.StatusConflict)
|
|
return
|
|
}
|
|
saleStatus = "completed"
|
|
}
|
|
|
|
// MEDIUM-3a coverage: an admin till sale that moves money (cash,
|
|
// card_machine, online_square — saved_card already writes its own audit row
|
|
// above) is an admin money action. on_the_house creates no money movement
|
|
// and is not audited. Record one admin_audit_log row per completed sale
|
|
// (best-effort, own transaction — a failed audit write never fails the
|
|
// sale).
|
|
if saleStatus == "completed" && req.PaymentMethod != "on_the_house" && req.PaymentMethod != "saved_card" {
|
|
InsertAdminAuditCharge(ctx, adminID, "", "admin_till_sale", map[string]any{
|
|
"till_sale_id": tillSaleID,
|
|
"item_type": req.ItemType,
|
|
"gift_card_id": giftCardID,
|
|
"amount": req.Amount,
|
|
"method": req.PaymentMethod,
|
|
})
|
|
}
|
|
|
|
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) {
|
|
// Defense-in-depth admin check (S-1).
|
|
if !isAdminRequest(r) {
|
|
http.Error(w, "Admin access required", http.StatusForbidden)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
// A sale already swept to 'failed' (e.g. its checkout was cancelled as
|
|
// stale, or a definitive decline clawed back the card) must never report a
|
|
// live payment state: the poll would otherwise tell the admin the terminal
|
|
// is still waiting when the sale can no longer be completed. Fail loudly so
|
|
// a stale checkout that later resolves at Square is surfaced for manual
|
|
// reconciliation instead of silently confusing the operator.
|
|
if currentStatus == "failed" {
|
|
log.Printf("Till sale %s is already failed but checkout %s is still being polled — refusing to report a live state", tillSaleID, checkoutID)
|
|
http.Error(w, "Till sale already failed — manual reconciliation required", http.StatusNotFound)
|
|
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" {
|
|
// Serialize terminal-completion records per till sale — the one payment
|
|
// writer that previously lacked the advisory-lock pattern every other
|
|
// path uses. Two concurrent polls of the same checkout could both run
|
|
// the UPDATE + VAT (idempotent today, but a double-apply is a latent
|
|
// bug). Lock on the till-sale id so only one goroutine completes it.
|
|
// Bounded try-lock (R6) so a contended lock never blocks the pool.
|
|
pinConn, err := db.Conn.Acquire(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to acquire connection for till-completion lock: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer pinConn.Release()
|
|
lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:tillcomplete:"+tillSaleID)
|
|
if err != nil {
|
|
log.Printf("Failed to acquire till-completion serialization lock for %s: %v", tillSaleID, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !lockOK {
|
|
log.Printf("Till-completion serialization lock for %s not acquired within bound — a poll is already completing this sale", tillSaleID)
|
|
http.Error(w, "Payment in progress, try again", http.StatusConflict)
|
|
return
|
|
}
|
|
defer releasePaymentLock(pinConn, "crussell:tillcomplete:"+tillSaleID)
|
|
|
|
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)
|
|
}
|
|
}()
|
|
|
|
tag, err := tx.Exec(r.Context(), `
|
|
UPDATE till_sales
|
|
SET status = 'completed',
|
|
square_payment_id = $1,
|
|
updated_at = NOW()
|
|
WHERE id = $2 AND status = 'pending'
|
|
`, paymentResult.SquarePayID, tillSaleID)
|
|
if err != nil {
|
|
log.Printf("Failed to update till sale: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
// The sweep already failed this sale (or a clawback reverted the
|
|
// gift card) — completing it now would resurrect a sale whose card
|
|
// no longer exists. Fail loudly for manual reconciliation.
|
|
log.Printf("CRITICAL: checkout %s reports COMPLETED but till sale %s is no longer pending — refusing to complete; MANUAL RECONCILIATION REQUIRED", checkoutID, tillSaleID)
|
|
http.Error(w, "Till sale no longer pending — manual reconciliation required", http.StatusNotFound)
|
|
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)
|
|
}
|
|
}
|