Compare commits

..

7 Commits

Author SHA1 Message Date
88bbdd2c5d style(tui): reword (coming soon) to (WIP) 2026-06-09 11:54:58 +02:00
efa2d83cab fix: disable add-card-via-ssh, mark coming soo, skip in cursor nav 2026-06-09 11:50:23 +02:00
c112797875 refactor(sca): thread context.Context through reconciler entry points (#41)
* refactor(sca): thread context.Context through reconciler entry points

* test(sca): add mid-loop cancel test for stale 3ds sweep

* refactor(api): rename shutdown ctx to shutdownCtx for clarity

* style(sca): fix typo in ReconcileUnshipped commend
2026-06-09 00:31:56 +02:00
75225548ae feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep (#40)
* feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep

* test: assert sweep counter labels in remaining branch tests
2026-06-08 23:11:44 +02:00
53e934be3b chore(cap): raise per-order spend cap default from 200 to 500 (#39) 2026-06-07 23:47:43 +02:00
fc02f331d2 feat(audit): emit cart_rejected event when per-order cap blocks checkout (#38)
* feat(audit): emit cart_rejected event when per-order cap blocks checkout

* refactor(audit): drop dead persist call + harden cart_rejected test capture
2026-06-07 23:25:30 +02:00
bb960c31f8 feat(sca): sweep abandoned 3ds requires_action orders to failed (#37)
* feat(sca): sweep abandoned 3ds requires_action orders to failed

* fix(sca): address pr review on abandoned 3ds sweep

- Drop stripe.Key write in ReconcileStale3DSOrders; wire once in api/main.go after cfg.Load so concurrent reconcilers don't
race the SDK global.
- Add empty-PI short-circuit before paymentintent.Get to bury a future regression in a slog.Warn instead of a Stripe 4xx.
- Swap fmt.Printf for slog.Warn with k/v attrs in loadAbandoned3DSThresholdMinutes so Loki sees structured fields instead of
 literal %q/%d.
- Comment the updated_at predicate (RetryAuth bumps it via GORM auto-stamp, intentional grace-window extension) + the
conditional-UPDATE/map-form choice.
- Tests: processing/requires_capture skip, unknown-status skip, stripe-error skip, empty-PI skip, full env-parse table for
loadAbandoned3DSThresholdMinutes.

* fix(stripe): wire key once at init drop per-call writes

* refactor(stripe): drop dead stripeKey fields and pre-call sdk params
2026-06-07 18:16:33 +02:00
14 changed files with 411 additions and 82 deletions

View File

@@ -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 {

View File

@@ -382,6 +382,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if h.maxOrderCents > 0 && total > h.maxOrderCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, h.maxOrderCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -746,6 +747,19 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
}
// Capture audit slog ONLY for ConvertCart's emit window. Placed
// here (not at top of t.Run) so setup INFO logs from
// database.Connect/Migrate/Seed don't pollute the buffer with
// non-audit records - json.Unmarshal below requires a single
// JSON object, not multi-document input. slog.Default is
// process-global - do NOT add t.Parallel() to subtests here.
var auditBuf bytes.Buffer
if tc.expectOverCap {
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&auditBuf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
}
// Convert
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder()
@@ -772,6 +786,27 @@ func TestConvertCartLimit(t *testing.T) {
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
}
if auditBuf.Len() == 0 {
t.Fatalf("audit: expected cart_rejected slog line, got nothing")
}
var rec map[string]any
if err := json.Unmarshal(auditBuf.Bytes(), &rec); err != nil {
t.Fatalf("audit: decode slog record: %v\nraw: %s", err, auditBuf.String())
}
if got := rec["event"]; got != "cart_rejected" {
t.Errorf("audit event: want cart_rejected, got %v", got)
}
if n, _ := rec["user_id"].(float64); uint(n) != user.ID {
t.Errorf("audit user_id: want %d, got %v", user.ID, rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != tc.quantity*pinnedPrice {
t.Errorf("audit total_cents: want %d, got %v", tc.quantity*pinnedPrice, rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != tc.capCents {
t.Errorf("audit cap_cents: want %d, got %v", tc.capCents, rec["cap_cents"])
}
}
})
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
View File

@@ -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

View File

@@ -24,6 +24,12 @@ const EventOrderRequiresAction = "order_requires_action"
// stays decoupled from sweep activity
const EventOrder3DSAbandoned = "order_3ds_abandoned"
// EventCartRejected is the audit event-type string emitted when the per-order
// spend cap blocks a checkout BEFORE an order row is created. The event lives
// in slog only, persist() no-ops on zero order id, which is the correct shape
// here (cap rejection is upstream of order state). See caveat #13.
const EventCartRejected = "cart_rejected"
type eventType string
const (
@@ -36,6 +42,7 @@ const (
eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
eventCartRejected eventType = EventCartRejected
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased"
@@ -46,19 +53,21 @@ const (
)
type event struct {
Event eventType
UserID uint
OrderID uint
CardID uint
Amount int
StripeID string
CardLast4 string
CardBrand string
Carrier string
Tracking string
Status string
Error string
Actor string
Event eventType
UserID uint
OrderID uint
CardID uint
Amount int
StripeID string
CardLast4 string
CardBrand string
Carrier string
Tracking string
Status string
Error string
Actor string
TotalCents int
CapCents int
}
func (e event) attrs() []any {
@@ -99,6 +108,12 @@ func (e event) attrs() []any {
if e.Actor != "" {
a = append(a, "actor", e.Actor)
}
if e.TotalCents != 0 {
a = append(a, "total_cents", e.TotalCents)
}
if e.CapCents != 0 {
a = append(a, "cap_cents", e.CapCents)
}
return a
}
@@ -349,3 +364,19 @@ func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrder3DSAbandoned, e)
}
// CartRejected records the per-order cap blocking a checkout BEFORE an
// order row is created. Slog-only by design: cap rejection runs upstream
// of order creation so there is no order id to scope a persistent
// OrderEvent row. persist() is intentionally not called - future refactors
// of the persist() zero-id guard must not silently start writing rows here.
// Counterpart to the validation_over_limit Prometheus counter.
func CartRejected(userID uint, totalCents, capCents int) {
e := event{
Event: eventCartRejected,
UserID: userID,
TotalCents: totalCents,
CapCents: capCents,
}
slog.Info("audit", e.attrs()...)
}

41
pkg/audit/audit_test.go Normal file
View File

@@ -0,0 +1,41 @@
package audit
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestCartRejected(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
CartRejected(42, 25000, 20000)
raw := strings.TrimSpace(buf.String())
if raw == "" {
t.Fatalf("CartRejected: no slog output captured")
}
var rec map[string]any
if err := json.Unmarshal([]byte(raw), &rec); err != nil {
t.Fatalf("decode slog record: %v\nraw: %s", err, raw)
}
if got, want := rec["event"], "cart_rejected"; got != want {
t.Errorf("event: want %q, got %v", want, got)
}
if n, _ := rec["user_id"].(float64); int(n) != 42 {
t.Errorf("user_id want 42, got %v", rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != 25000 {
t.Errorf("total_cents: want 25000, got %v", rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != 20000 {
t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"])
}
}

View File

@@ -137,11 +137,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

View File

@@ -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 {

View File

@@ -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)
}
}

View 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")
}
}