Fix silent tip/gift-card money loss on pending retry; terminal checkout wire; sentinel error; docs
CRITICAL — same-amount tip retry silently never charged: - CreateTipPayment idempotency check now only short-circuits when the existing record is 'completed'. A 'pending' record (previous Square call failed) is REUSED and the charge re-attempted with the same key (Square dedups safely), instead of returning the stale pending record as 200 with a success toast and no charge. - Same fix in BuyGiftCard: pending records trigger a re-attempt, not a false-success response. Unique idempotency_key constraint means the pending record must be reused, not re-inserted. - Fixes the savepoint/rollback interaction: the nested tx (savepoint) is now committed in the reuse path so the deferred rollback doesn't undo the later status UPDATE on the same connection. - Regression test: TestTipPayment_RetryPending_ReattemptsCharge verifies a pending record + same-key retry re-attempts and completes, reusing the record (count stays 1). MAJOR — terminal checkout wire contract: - device_id now sent as checkout.device_options.device_id (Square's required shape), not a top-level field which Square rejects with 400. - 'checkout pending' detection now uses typed sentinel ErrCheckoutPending with errors.Is in both handlers, matching mock and real HTTP client. MAJOR — exp_month/exp_year omitted from card creation payload when unset (now *int with omitempty) — Square would 400 on 0/0; expiry comes from the tokenized source. Docs: - README payments/infrastructure sections corrected (Web Payments SDK claim replaced with accurate P11-backlog note; dev mock parity described) - Future Work P11 updated to reflect raw-PAN rejection is now enforced in both mock and prod (new-card flows are a documented dead end) - Added plans/p11-square-web-payments-sdk.md: full implementation plan + handoff prompt for the agent picking up P11 (Web Payments SDK nonces)
This commit is contained in:
@@ -891,16 +891,27 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
paymentService := NewPaymentService()
|
||||
|
||||
// Idempotency: only short-circuit when the existing record is 'completed'.
|
||||
// A 'pending' record means the previous Square call failed — returning it
|
||||
// as 200 would show a success without ever charging. Re-attempt below with
|
||||
// the same key (Square dedups safely) and reuse the pending record.
|
||||
reusePendingID := ""
|
||||
if req.IdempotencyKey != "" {
|
||||
existing, err := paymentService.CheckIdempotencyByKey(ctx, req.IdempotencyKey)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check idempotency: %v", err)
|
||||
}
|
||||
if existing != nil {
|
||||
if err := json.NewEncoder(w).Encode(existing); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
if existing.Status == "completed" {
|
||||
if err := json.NewEncoder(w).Encode(existing); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if existing.Status == "pending" {
|
||||
reusePendingID = existing.ID
|
||||
log.Printf("[PAYMENTS] Reusing pending payment %s for idempotent gift-card retry (key %s)", existing.ID, req.IdempotencyKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,43 +969,62 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
|
||||
var buyPaymentID string
|
||||
fees := paymentService.CalculateFees(req.Amount, "online")
|
||||
record := PaymentRecord{
|
||||
PaymentType: "full",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "pending",
|
||||
Amount: amountPounds,
|
||||
SquarePaymentID: nil,
|
||||
IdempotencyKey: &req.IdempotencyKey,
|
||||
Fees: float64(fees) / 100.0,
|
||||
UserSavedCardID: savedCardID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &userID,
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&buyPaymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert payment record: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if reusePendingID != "" {
|
||||
// Reusing the pending record from a failed prior attempt — do not
|
||||
// insert a duplicate (idempotency_key is UNIQUE). Proceed straight to
|
||||
// the Square call, which dedups on the same key.
|
||||
buyPaymentID = reusePendingID
|
||||
} else {
|
||||
fees := paymentService.CalculateFees(req.Amount, "online")
|
||||
record := PaymentRecord{
|
||||
PaymentType: "full",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "pending",
|
||||
Amount: amountPounds,
|
||||
SquarePaymentID: nil,
|
||||
IdempotencyKey: &req.IdempotencyKey,
|
||||
Fees: float64(fees) / 100.0,
|
||||
UserSavedCardID: savedCardID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &userID,
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&buyPaymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert payment record: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply VAT to the pending payment
|
||||
vatCfg, vatErr := GetVATConfig(ctx, tx)
|
||||
if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" {
|
||||
if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", buyPaymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
|
||||
log.Printf("Failed to apply VAT to buy gift card payment %s: %v", buyPaymentID, vatExecErr)
|
||||
// Apply VAT to the pending payment
|
||||
vatCfg, vatErr := GetVATConfig(ctx, tx)
|
||||
if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" {
|
||||
if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", buyPaymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
|
||||
log.Printf("Failed to apply VAT to buy gift card payment %s: %v", buyPaymentID, vatExecErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit buy transaction: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit buy transaction: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
// Commit the (nested/savepoint) transaction in the reuse path too. It is
|
||||
// empty, but committing releases the savepoint so the deferred rollback
|
||||
// becomes a no-op — otherwise that rollback would undo the status UPDATE
|
||||
// executed later on the same connection.
|
||||
if reusePendingID != "" {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit buy transaction (reuse): %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: DB transaction committed — safe to call Square now.
|
||||
|
||||
Reference in New Issue
Block a user