Compare commits
8 Commits
feat/audit
...
fix/stripe
| Author | SHA1 | Date | |
|---|---|---|---|
| abb52c7db2 | |||
| c9f8b1e674 | |||
| d592249efb | |||
| cfc97eb1ff | |||
| c112797875 | |||
| 75225548ae | |||
| 53e934be3b | |||
| fc02f331d2 |
11
.github/workflows/deploy.yml
vendored
11
.github/workflows/deploy.yml
vendored
@@ -22,6 +22,15 @@ jobs:
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
port: ${{ secrets.SERVER_PORT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd ~/terminalShop
|
||||
git pull
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
deployed=$(git rev-parse HEAD)
|
||||
expected="${{ github.sha }}"
|
||||
if [ "$deployed" != "$expected" ]; then
|
||||
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
|
||||
exit 1
|
||||
fi
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
echo "deploy verified: HEAD=$deployed"
|
||||
|
||||
@@ -71,3 +71,4 @@ uses SQLite by default so there's nothing to spin up.
|
||||
## inspiration
|
||||
|
||||
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
|
||||
# something
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -58,7 +59,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
|
||||
t.Fatalf("seed cart: %v", err)
|
||||
}
|
||||
|
||||
ReconcileExpiredCards()
|
||||
ReconcileExpiredCards(context.Background())
|
||||
|
||||
var remaining []models.Card
|
||||
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
|
||||
@@ -117,7 +118,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
ReconcileExpiredCards()
|
||||
ReconcileExpiredCards(context.Background())
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
@@ -26,8 +28,8 @@ func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
|
||||
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
|
||||
// the case where the server crashed after charging but before the DB transaction
|
||||
// completed.
|
||||
func ReconcileOrders() {
|
||||
db := database.GetDB()
|
||||
func ReconcileOrders(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
@@ -41,9 +43,14 @@ func ReconcileOrders() {
|
||||
reconcileLog().Info("checking pending orders", "count", len(orders))
|
||||
|
||||
for _, order := range orders {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
params := &stripe.PaymentIntentSearchParams{
|
||||
SearchParams: stripe.SearchParams{
|
||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||
Context: ctx,
|
||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -89,8 +96,8 @@ func ReconcileOrders() {
|
||||
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
|
||||
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
|
||||
// stripe.Key is wired once at startup in api/main.go.
|
||||
func ReconcileStale3DSOrders(threshold time.Duration) {
|
||||
db := database.GetDB()
|
||||
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
// Predicate keys off updated_at, not created_at: respondRequiresAction
|
||||
@@ -109,19 +116,26 @@ func ReconcileStale3DSOrders(threshold time.Duration) {
|
||||
|
||||
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
|
||||
|
||||
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
|
||||
for i := range orders {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
order := &orders[i]
|
||||
if order.StripePaymentID == "" {
|
||||
// respondRequiresAction writes stripe_payment_id and status in
|
||||
// one UPDATE so this should be unreachable; guard anyway so a
|
||||
// future regression noisily logs instead of 4xx'ing Stripe.
|
||||
reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID)
|
||||
middleware.RecordAbandoned3DSSweep("missing_pi")
|
||||
continue
|
||||
}
|
||||
actual, err := paymentIntentGetFn(order.StripePaymentID, nil)
|
||||
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
|
||||
if err != nil {
|
||||
reconcileLog().Error("stripe get failed",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
|
||||
middleware.RecordAbandoned3DSSweep("stripe_error")
|
||||
continue
|
||||
}
|
||||
switch actual.Status {
|
||||
@@ -133,10 +147,12 @@ func ReconcileStale3DSOrders(threshold time.Duration) {
|
||||
// tick re-checks and surfaces the dropped webhook if it persists
|
||||
reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID)
|
||||
middleware.RecordAbandoned3DSSweep("succeeded_skip")
|
||||
case stripe.PaymentIntentStatusProcessing,
|
||||
stripe.PaymentIntentStatusRequiresCapture:
|
||||
reconcileLog().Info("payment intent still transitioning; skipping",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
|
||||
middleware.RecordAbandoned3DSSweep("processing_skip")
|
||||
case stripe.PaymentIntentStatusRequiresAction,
|
||||
stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
stripe.PaymentIntentStatusRequiresConfirmation,
|
||||
@@ -145,6 +161,7 @@ func ReconcileStale3DSOrders(threshold time.Duration) {
|
||||
default:
|
||||
reconcileLog().Warn("unrecognized stripe status; skipping",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
|
||||
middleware.RecordAbandoned3DSSweep("unrecognized_status")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,24 +179,27 @@ func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string)
|
||||
Updates(map[string]any{"status": models.OrderStatusFailed})
|
||||
if res.Error != nil {
|
||||
reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error)
|
||||
middleware.RecordAbandoned3DSSweep("flip_failed")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
|
||||
"order_id", order.ID)
|
||||
middleware.RecordAbandoned3DSSweep("flip_noop")
|
||||
return
|
||||
}
|
||||
audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID)
|
||||
reconcileLog().Info("3ds abandoned order flipped to failed",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus)
|
||||
middleware.RecordAbandoned3DSSweep("flipped")
|
||||
}
|
||||
|
||||
// ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
|
||||
// Read paths filter out expired rows so users never see them; this job drives
|
||||
// the Stripe detach + audit + physical row delete out of band so request
|
||||
// handlers never block on Stripe API calls during a card sweep.
|
||||
func ReconcileExpiredCards() {
|
||||
db := database.GetDB()
|
||||
func ReconcileExpiredCards(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var cards []models.Card
|
||||
if err := db.Where(
|
||||
@@ -195,16 +215,20 @@ func ReconcileExpiredCards() {
|
||||
|
||||
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
|
||||
for i := range cards {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
if err := expireStoredCard(db, &cards[i]); err != nil {
|
||||
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
|
||||
// number and posts single Slack reminder. Pure read + notif
|
||||
func ReconcileUnshipped() {
|
||||
db := database.GetDB()
|
||||
// ReconcileUnshipped finds paid orders older than 24h with no tracking
|
||||
// number and posts a single Slack reminder. Pure read + notify.
|
||||
func ReconcileUnshipped(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
)
|
||||
|
||||
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
|
||||
@@ -39,7 +42,10 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
|
||||
before := testutil.ToFloat64(flipped)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -57,6 +63,9 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
|
||||
}
|
||||
if got := testutil.ToFloat64(flipped) - before; got != 1 {
|
||||
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
|
||||
@@ -85,7 +94,7 @@ func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
if called {
|
||||
t.Fatalf("stripe Get called for in-window row")
|
||||
@@ -124,7 +133,10 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
|
||||
before := testutil.ToFloat64(skip)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -133,6 +145,9 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(skip) - before; got != 1 {
|
||||
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
|
||||
@@ -161,7 +176,10 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
|
||||
before := testutil.ToFloat64(noop)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -170,6 +188,9 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(noop) - before; got != 1 {
|
||||
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
|
||||
@@ -196,7 +217,7 @@ func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -232,7 +253,7 @@ func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var retryCount int64
|
||||
if err := db.Model(&models.OrderEvent{}).
|
||||
@@ -275,7 +296,10 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
|
||||
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
|
||||
before := testutil.ToFloat64(skip)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -284,6 +308,9 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(skip) - before; got != 1 {
|
||||
t.Fatalf("want 1 processing_skip increment, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -312,7 +339,10 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
|
||||
before := testutil.ToFloat64(unknown)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -321,6 +351,9 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(unknown) - before; got != 1 {
|
||||
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
|
||||
@@ -344,7 +377,10 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
|
||||
return nil, fmt.Errorf("simulated stripe outage")
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
|
||||
before := testutil.ToFloat64(stripeErr)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -353,6 +389,9 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
|
||||
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
|
||||
@@ -381,7 +420,10 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
|
||||
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
|
||||
before := testutil.ToFloat64(missing)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
if called {
|
||||
t.Fatalf("stripe Get called for empty-PI row")
|
||||
@@ -393,6 +435,55 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(missing) - before; got != 1 {
|
||||
t.Fatalf("want 1 missing_pi increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
|
||||
// ctx.Err() check stops the sweep once the supervising context cancels, so a
|
||||
// SIGTERM during a large stale-3DS batch does not block the binary past the
|
||||
// 30s graceful-shutdown window in api/main.go
|
||||
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
|
||||
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
calls := 0
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
cancel()
|
||||
}
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
ReconcileStale3DSOrders(ctx, 30*time.Minute)
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
|
||||
}
|
||||
var stillPending int64
|
||||
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
|
||||
t.Fatalf("count requires_action rows: %v", err)
|
||||
}
|
||||
if stillPending < 1 {
|
||||
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRequiresActionOrder inserts a card + order pair in the requires_action
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -52,8 +53,17 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Verify the Stripe signature. Reject anything that doesn't match.
|
||||
if h.webhookSecret == "" {
|
||||
// No secret configured — log a loud warning but still process in dev.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
|
||||
// Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
|
||||
// in production, so the server should never boot into this state; this is
|
||||
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
|
||||
// Slack handlers, which already refuse to run without their secret.
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
|
||||
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
|
||||
return
|
||||
}
|
||||
// Development only; warn loudly but still process so local testing works.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
|
||||
} else {
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
|
||||
@@ -124,6 +134,20 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
return
|
||||
}
|
||||
|
||||
// Defense in depth: even behind a valid signature, refuse to mark an order
|
||||
// paid unless the PaymentIntent's amount and currency match what we charged.
|
||||
// Under ConvertCart these always match (Amount == order.Total at creation), so
|
||||
// a mismatch means a malformed or unexpected event we should not trust.
|
||||
if pi.Amount != int64(order.Total) {
|
||||
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
|
||||
return
|
||||
}
|
||||
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
|
||||
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
|
||||
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
|
||||
return
|
||||
}
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"status": models.OrderStatusPaid,
|
||||
|
||||
@@ -222,3 +222,38 @@ func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
|
||||
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
|
||||
|
||||
// PI claims 1 cent; seed order.Total is 500.
|
||||
pi := map[string]any{
|
||||
"id": order.StripePaymentID,
|
||||
"amount": 1,
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||
}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
evt := stripe.Event{
|
||||
Type: "payment_intent.succeeded",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reloaded order: %v", err)
|
||||
}
|
||||
if reloaded.Status == models.OrderStatusPaid {
|
||||
t.Fatalf("order marked paid despite amount mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
22
api/main.go
22
api/main.go
@@ -114,7 +114,7 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileOrders()
|
||||
handlers.ReconcileOrders(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -122,14 +122,18 @@ func main() {
|
||||
}()
|
||||
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
handlers.ReconcileUnshipped()
|
||||
select {
|
||||
case <-time.After(30 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
handlers.ReconcileUnshipped(ctx)
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileUnshipped()
|
||||
handlers.ReconcileUnshipped(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -145,7 +149,7 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileExpiredCards()
|
||||
handlers.ReconcileExpiredCards(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -159,7 +163,7 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileStale3DSOrders(threshold)
|
||||
handlers.ReconcileStale3DSOrders(ctx, threshold)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -174,10 +178,10 @@ func main() {
|
||||
log.Println("Server is shutting down...")
|
||||
|
||||
// Graceful shutdown with 30 second timeout
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@ var (
|
||||
[]string{"outcome"},
|
||||
)
|
||||
|
||||
abandoned3DSSweepTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "abandoned_3ds_sweep_total",
|
||||
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
|
||||
},
|
||||
[]string{"outcome"},
|
||||
)
|
||||
|
||||
orderValueDollars = promauto.NewHistogram(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "order_value_dollars",
|
||||
@@ -173,6 +181,19 @@ func ObserveOrderValueCents(cents int) {
|
||||
orderValueDollars.Observe(float64(cents) / 100.0)
|
||||
}
|
||||
|
||||
// RecordAbandoned3DSSweep records the outcome of one row processed by
|
||||
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
|
||||
// Help string.
|
||||
func RecordAbandoned3DSSweep(outcome string) {
|
||||
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
|
||||
}
|
||||
|
||||
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
|
||||
// per-label values via prometheus testutil. Not used in production code.
|
||||
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
|
||||
return abandoned3DSSweepTotal
|
||||
}
|
||||
|
||||
// ObserveStripeRequest records latency of a Stripe API call.
|
||||
func ObserveStripeRequest(op, outcome string, durSec float64) {
|
||||
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)
|
||||
|
||||
1
go.mod
1
go.mod
@@ -81,6 +81,7 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
@@ -92,6 +92,7 @@ func (c *Config) validate() error {
|
||||
{"JWT_SECRET", c.JWTSecret},
|
||||
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
|
||||
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
|
||||
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
|
||||
}
|
||||
|
||||
var missing []string
|
||||
@@ -137,11 +138,13 @@ func getEnvOrDefault(key, defaultValue string) string {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
|
||||
// the cap. Garbage or negative values fall back to the default with a warning
|
||||
// so an operator typo cannot silently disable a fraud control.
|
||||
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the
|
||||
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the
|
||||
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or
|
||||
// negative values fall back to the default with a warning so an operator
|
||||
// typo cannot silently disable a fraud control.
|
||||
func loadMaxOrderCents() int {
|
||||
const defaultCap = 20000
|
||||
const defaultCap = 50000
|
||||
raw := os.Getenv("MAX_ORDER_CENTS")
|
||||
if raw == "" {
|
||||
return defaultCap
|
||||
|
||||
@@ -24,12 +24,12 @@ func TestLoadMaxOrderCents(t *testing.T) {
|
||||
env string // "" means unset
|
||||
want int
|
||||
}{
|
||||
{"empty falls back to default", "", 20000},
|
||||
{"empty falls back to default", "", 50000},
|
||||
{"explicit zero disables", "0", 0},
|
||||
{"valid override", "50000", 50000},
|
||||
{"negative falls back to default", "-1", 20000},
|
||||
{"garbage falls back to default", "two hundred", 20000},
|
||||
{"empty after trim falls back to default", "", 20000},
|
||||
{"valid override", "30000", 30000},
|
||||
{"negative falls back to default", "-1", 50000},
|
||||
{"garbage falls back to default", "two hundred", 50000},
|
||||
{"empty after trim falls back to default", "", 50000},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -355,11 +355,16 @@ func (m Model) RenderCardList() string {
|
||||
boxes = append(boxes, box)
|
||||
}
|
||||
|
||||
sshFocused := m.payment.cardCursor == len(m.SavedCards)
|
||||
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
|
||||
// SSH inline card-add is dormant until i have a norwegian org
|
||||
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is
|
||||
// discoverable, but render it dimmed and exclude it from cursor space
|
||||
// (handled in PaymentUpdate below) so it can not be picked
|
||||
dim := m.theme.TextDim().Render
|
||||
sshLabel := dim("+ add card via ssh") + " " + dim("(WIP)")
|
||||
sshBox := m.CreateBox(m.formatListItem(sshLabel, false), false)
|
||||
boxes = append(boxes, sshBox)
|
||||
|
||||
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
|
||||
httpsFocused := m.payment.cardCursor == len(m.SavedCards)
|
||||
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
|
||||
boxes = append(boxes, httpsBox)
|
||||
|
||||
@@ -501,7 +506,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.payment.cardCursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.payment.cardCursor < len(m.SavedCards)+1 {
|
||||
if m.payment.cardCursor < len(m.SavedCards) {
|
||||
m.payment.cardCursor++
|
||||
}
|
||||
|
||||
@@ -511,17 +516,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.SelectedCard = &selected
|
||||
return m.ReviewSwitch()
|
||||
}
|
||||
if m.payment.cardCursor == len(m.SavedCards) {
|
||||
m.payment.view = 1
|
||||
m.payment.form = m.InitPaymentForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, m.payment.form.form.Init()
|
||||
}
|
||||
// Browser payment
|
||||
// Browser flow, the only reachable add-card path while the SSH
|
||||
// inline form is dormant (see RenderCardList)
|
||||
m.payment.view = 2
|
||||
m.payment.collectURL = nil
|
||||
m.payment.collectTimeOut = false
|
||||
@@ -546,16 +542,6 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
|
||||
m.payment.cardCursor--
|
||||
}
|
||||
if len(m.SavedCards) == 0 {
|
||||
m.payment.view = 1
|
||||
m.payment.form = m.InitPaymentForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
|
||||
}
|
||||
return m, m.deleteCardCmd(card.ID)
|
||||
}
|
||||
}
|
||||
|
||||
91
pkg/tui/payment_form_test.go
Normal file
91
pkg/tui/payment_form_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// newPaymentCardListModel parks a Model on the payment page card-list view
|
||||
// with n synthetic saved cards. Baseline for assertint that the dormant
|
||||
// "+ add card via ssh" row is dimmed and unreachable by cursor navigation.
|
||||
func newPaymentCardListModel(t *testing.T, n int) Model {
|
||||
t.Helper()
|
||||
m := NewModel("test")
|
||||
m = m.updateLayout(120, 40)
|
||||
m.currentPage = paymentPage
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
m.payment.cardCursor = 0
|
||||
for i := range n {
|
||||
m.SavedCards = append(m.SavedCards, models.Card{
|
||||
ID: uint(i + 1),
|
||||
Brand: "visa",
|
||||
Last4: "4242",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func keyEnter() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
|
||||
|
||||
func TestRenderCardList_SshOptionComingSoon(t *testing.T) {
|
||||
m := newPaymentCardListModel(t, 1)
|
||||
out := m.RenderCardList()
|
||||
if !strings.Contains(out, "add card via ssh") {
|
||||
t.Fatalf("ssh option missing from card list output:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "WIP") {
|
||||
t.Fatalf("want 'WIP' marked on ssh option:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentUpdate_DownNavigationStopsAtBrowserSlot(t *testing.T) {
|
||||
m := newPaymentCardListModel(t, 2)
|
||||
for range 10 {
|
||||
var cmd tea.Cmd
|
||||
m, cmd = m.PaymentUpdate(keyJ())
|
||||
_ = cmd
|
||||
}
|
||||
want := len(m.SavedCards) // browser slot; ssh slot in unreachable
|
||||
if m.payment.cardCursor != want {
|
||||
t.Fatalf("cursor stopped at %d, want %d (browser slot)", m.payment.cardCursor, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentUpdate_EnterOnBrowserSlotLaunchesBrowserFlow(t *testing.T) {
|
||||
m := newPaymentCardListModel(t, 1)
|
||||
m.payment.cardCursor = len(m.SavedCards) // browser slot
|
||||
newM, cmd := m.PaymentUpdate(keyEnter())
|
||||
if newM.payment.view != 2 {
|
||||
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
|
||||
}
|
||||
if newM.payment.form != nil {
|
||||
t.Fatalf("payment.form must be nil after enter; ssh inline form is dormant")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected collectCardCmd, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentUpdate_EnterWithZeroCardsLaunchesBrowserFlow(t *testing.T) {
|
||||
m := newPaymentCardListModel(t, 0)
|
||||
if m.payment.cardCursor != 0 {
|
||||
t.Fatalf("default cursor = %d, want 0 ", m.payment.cardCursor)
|
||||
}
|
||||
newM, cmd := m.PaymentUpdate(keyEnter())
|
||||
if newM.payment.view != 2 {
|
||||
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
|
||||
}
|
||||
if newM.payment.form != nil {
|
||||
t.Fatalf("payment.form must be nil; ssh inline form is dormant")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected collectCardCmd, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user