Compare commits
7 Commits
feat/audit
...
chore/depl
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f80ef973b | |||
| 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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
41
pkg/audit/audit_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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