Compare commits

..

1 Commits

Author SHA1 Message Date
1c891cebe6 feat(tuie): drive roast meter from product roast_level field 2026-06-11 16:24:23 +02:00
37 changed files with 82 additions and 782 deletions

1
.gitignore vendored
View File

@@ -7,7 +7,6 @@
docs/sca-psd2-compliance.md
accept-a-payment/
codemap/
FULFILLMENT.md

View File

@@ -1,3 +0,0 @@
// Command api is the HTTP backend: it boots the router, bolts on all the
// middleware, and answers the requests the TUI throws at it.
package main

View File

@@ -1,97 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// AdminUserHandler serves admin-gated user-administration endpoints. Routes that
// use it are mounted behind middleware.RequireAdmin (X-Admin-Key), not the JWT
// auth used by customer-facing endpoints.
type AdminUserHandler struct{}
// NewAdminUserHandler constructs an AdminUserHandler.
func NewAdminUserHandler() *AdminUserHandler {
return &AdminUserHandler{}
}
// SetUserOrderCap sets or clears a user's per-user spend-cap override
// (User.MaxOrderCents). Body: {"max_order_cents": <int|null>}.
// - null -> clears the override; the user reverts to the global MAX_ORDER_CENTS
// - 0 -> per-user off-switch (disables the cap for this user)
// - >0 -> custom ceiling
//
// Negative values are rejected here at the write boundary (HTTP 400) to keep bad
// data out of the column. The read path in cart.go:ConvertCart independently
// defends against a negative that arrives via a direct DB write (falls back to
// the global cap), so the two layers cover each other.
//
// Adapted from the admin OrderHandler.UpdateTracking pattern in
// api/handlers/orders.go (URL id param -> body decode -> row lookup -> update ->
// audit -> respond).
func (h *AdminUserHandler) SetUserOrderCap(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid user id", nil)
return
}
// Decode raw so an explicit null (clear) is distinguishable from an absent key.
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawCap, ok := raw["max_order_cents"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD",
"max_order_cents is required (use null to clear the override)", nil)
return
}
var capCents *int
if string(rawCap) != "null" {
var n int
if err := json.Unmarshal(rawCap, &n); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY",
"max_order_cents must be an integer or null", nil)
return
}
if n < 0 {
utils.RespondError(w, http.StatusBadRequest, "INVALID_CAP",
"max_order_cents must be >= 0 (use null to clear the override)", nil)
return
}
capCents = &n
}
var user models.User
if err := db.First(&user, id).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// Map form so a nil pointer persists SQL NULL; a struct Updates would skip it.
if err := db.Model(&user).Updates(map[string]any{"max_order_cents": capCents}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update order cap", nil)
return
}
audit.UserOrderCapSet("admin", user.ID, capCents)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"max_order_cents": capCents,
})
}

View File

@@ -1,194 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// adminCapRequest builds a PATCH request carrying the {id} chi URL param, as the
// admin order-cap route would. No JWT — the route is gated by RequireAdmin.
func adminCapRequest(idParam, body string) *http.Request {
req := httptest.NewRequest("PATCH", "/api/v1/users/"+idParam+"/order-cap", strings.NewReader(body))
rc := chi.NewRouteContext()
rc.URLParams.Add("id", idParam)
return req.WithContext(contextWithRoute(req.Context(), rc))
}
func eqIntPtr(a, b *int) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func fmtIntPtr(p *int) string {
if p == nil {
return "<nil>"
}
return strconv.Itoa(*p)
}
func TestSetUserOrderCap(t *testing.T) {
cases := []struct {
name string
seed *int // starting MaxOrderCents on the user row
body string
wantStatus int
wantCode string // error code; "" means success
wantCap *int // expected DB value after a successful update
}{
{"set positive", nil, `{"max_order_cents":7500}`, http.StatusOK, "", intPtr(7500)},
{"set zero off-switch", nil, `{"max_order_cents":0}`, http.StatusOK, "", intPtr(0)},
{"clear with null reverts to global", intPtr(500), `{"max_order_cents":null}`, http.StatusOK, "", nil},
{"overwrite existing value", intPtr(500), `{"max_order_cents":12000}`, http.StatusOK, "", intPtr(12000)},
{"negative rejected", nil, `{"max_order_cents":-1}`, http.StatusBadRequest, "INVALID_CAP", nil},
{"missing field rejected", nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
{"non-integer rejected", nil, `{"max_order_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
{"malformed json rejected", nil, `{`, http.StatusBadRequest, "INVALID_BODY", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
if tc.seed != nil {
if err := db.Model(&user).Update("max_order_cents", *tc.seed).Error; err != nil {
t.Fatalf("seed cap: %v", err)
}
}
handler := NewAdminUserHandler()
req := adminCapRequest(strconv.FormatUint(uint64(user.ID), 10), tc.body)
w := httptest.NewRecorder()
handler.SetUserOrderCap(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error.Code != tc.wantCode {
t.Errorf("error code: want %q got %q", tc.wantCode, resp.Error.Code)
}
var got models.User
if err := db.First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if tc.wantStatus == http.StatusOK {
if !eqIntPtr(got.MaxOrderCents, tc.wantCap) {
t.Errorf("db MaxOrderCents: want %s got %s", fmtIntPtr(tc.wantCap), fmtIntPtr(got.MaxOrderCents))
}
} else if !eqIntPtr(got.MaxOrderCents, tc.seed) {
// A rejected request must not mutate the column.
t.Errorf("rejected request changed db cap: want %s got %s", fmtIntPtr(tc.seed), fmtIntPtr(got.MaxOrderCents))
}
})
}
}
func TestSetUserOrderCap_InvalidID(t *testing.T) {
setupOrderTestDB(t)
w := httptest.NewRecorder()
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("abc", `{"max_order_cents":100}`))
if w.Code != http.StatusBadRequest {
t.Fatalf("status: want 400 got %d", w.Code)
}
assertErrCode(t, w, "INVALID_ID")
}
func TestSetUserOrderCap_UserNotFound(t *testing.T) {
setupOrderTestDB(t)
w := httptest.NewRecorder()
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("99999", `{"max_order_cents":100}`))
if w.Code != http.StatusNotFound {
t.Fatalf("status: want 404 got %d", w.Code)
}
assertErrCode(t, w, "USER_NOT_FOUND")
}
// TestSetUserOrderCap_AdminGate exercises the RequireAdmin middleware through a
// real router (the handler-direct tests above bypass middleware).
func TestSetUserOrderCap_AdminGate(t *testing.T) {
user := setupOrderTestDB(t)
idStr := strconv.FormatUint(uint64(user.ID), 10)
router := func() http.Handler {
r := chi.NewRouter()
r.With(middleware.RequireAdmin).
Patch("/api/v1/users/{id}/order-cap", NewAdminUserHandler().SetUserOrderCap)
return r
}
newReq := func() *http.Request {
return httptest.NewRequest("PATCH", "/api/v1/users/"+idStr+"/order-cap",
strings.NewReader(`{"max_order_cents":100}`))
}
t.Run("disabled when ADMIN_API_KEY unset", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "")
w := httptest.NewRecorder()
router().ServeHTTP(w, newReq())
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status: want 503 got %d", w.Code)
}
assertErrCode(t, w, "ADMIN_DISABLED")
})
t.Run("rejects wrong key", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "secret")
req := newReq()
req.Header.Set("X-Admin-Key", "nope")
w := httptest.NewRecorder()
router().ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401 got %d", w.Code)
}
assertErrCode(t, w, "UNAUTHORIZED")
})
t.Run("accepts correct key", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "secret")
req := newReq()
req.Header.Set("X-Admin-Key", "secret")
w := httptest.NewRecorder()
router().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: want 200 got %d (%s)", w.Code, w.Body.String())
}
var got models.User
if err := database.GetDB().First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if !eqIntPtr(got.MaxOrderCents, intPtr(100)) {
t.Errorf("db MaxOrderCents: want 100 got %s", fmtIntPtr(got.MaxOrderCents))
}
})
}
func assertErrCode(t *testing.T, w *httptest.ResponseRecorder, want string) {
t.Helper()
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error.Code != want {
t.Errorf("error code: want %q got %q", want, resp.Error.Code)
}
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
@@ -381,46 +380,14 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
total := subtotal + cart.ShippingCost
// Per-user overridable spend cap. The override may raise OR lower the global
// cap — operators set it in both directions deliberately. A negative override
// is nonsense: ignore it and fall back to the global so an erroneous DB write
// cannot silently disable a fraud control (mirrors loadMaxOrderCents in
// pkg/config/config.go, which defaults a negative MAX_ORDER_CENTS). An explicit
// 0 is a valid per-user off-switch (same semantics as the global).
capCents := h.maxOrderCents
if user.MaxOrderCents != nil {
if *user.MaxOrderCents < 0 {
slog.Warn("ignoring negative per-user order cap, using global",
"user_id", user.ID,
"user_cap_cents", *user.MaxOrderCents,
"global_cap_cents", h.maxOrderCents)
} else {
capCents = *user.MaxOrderCents
}
}
// A user-set self-limit may only TIGHTEN the effective cap, never raise it.
// a compromised account must not be able to lift its own ceiling to drain a
// stolen card. Unlike the admin/global cap (where 0 is an off-switch), a
// present self-limit always activates a ceiling, a self-limit of 0 means
// "block everything", not "disable", so it folds in via min() and can only
// lower capCents. A negative value is ignored (mirrors the maxOrderCents
// guard above); this min() is the authoritative lower-only guard.
capActive := capCents > 0
if user.SelfLimitCents != nil && *user.SelfLimitCents >= 0 {
if self := *user.SelfLimitCents; !capActive || self < capCents {
capCents = self
}
capActive = true
}
if capActive && total > capCents {
if h.maxOrderCents > 0 && total > h.maxOrderCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, capCents)
audit.CartRejected(userID, total, h.maxOrderCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(capCents)/100),
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{
"total_cents": total,
"limit_cents": capCents,
"limit_cents": h.maxOrderCents,
})
return
}

View File

@@ -670,67 +670,27 @@ func TestCartIsolation(t *testing.T) {
}
}
// intPtr returns a pointer to i, for seeding the nullable User.MaxOrderCents
// per-user cap override in table tests.
func intPtr(i int) *int { return &i }
func TestConvertCartLimit(t *testing.T) {
cases := []struct {
name string
capCents int
quantity int
expectOverCap bool
userCap *int // per-user admin override; nil = inherit the global capCents
selfLimit *int // user self-limit; nil = none. Lower-only: tightens, never raises.
}{
{"over cap rejects", 20000, 41, true, nil, nil}, // $205 vs global $200
{"at cap accepts", 20000, 40, false, nil, nil}, // $200 boundary
{"under cap accepts", 20000, 39, false, nil, nil}, // $195
{"zero cap disables", 0, 1000, false, nil, nil}, // explicit global off-switch
// Per-user admin override (User.MaxOrderCents) takes precedence over the global.
{"user override below global rejects", 20000, 12, true, intPtr(5000), nil}, // $60 > $50 user cap (global $200 would allow)
{"user override above global accepts", 20000, 41, false, intPtr(50000), nil}, // $205 < $500 user cap (global $200 would reject)
{"user override zero disables per user", 20000, 1000, false, intPtr(0), nil}, // per-user off-switch over a live global
{"nil override falls back to global", 20000, 41, true, nil, nil}, // explicit nil == inherit global $200
// A negative override is nonsense and must NOT disable the cap; it falls
// back to the global so a bad DB write can't void a fraud control. Here
// $205 > global $200 still rejects, at the GLOBAL cap (not the -1).
{"user override negative falls back to global", 20000, 41, true, intPtr(-1), nil},
// Self-limit (User.SelfLimitCents) is LOWER-ONLY: it can tighten the effective
// cap below the admin/global value but must never raise it.
{"self limit below global rejects", 20000, 12, true, nil, intPtr(5000)}, // $60 > $50 self-limit (global $200 would allow)
{"self limit under accepts", 20000, 8, false, nil, intPtr(5000)}, // $40 < $50 self-limit
{"self limit cannot raise above global", 20000, 50, true, nil, intPtr(30000)}, // $250: min($200 global,$300 self)=$200 still rejects
{"self limit tightens disabled global", 0, 12, true, nil, intPtr(5000)}, // global off-switch, self-limit $50 still caps; $60 rejects
{"self limit zero blocks everything", 20000, 1, true, nil, intPtr(0)}, // $5 > $0: self-limit 0 is "block all", NOT an off-switch
{"self limit negative ignored", 20000, 41, true, nil, intPtr(-1)}, // self ignored; rejects at global $200
{"self limit tightens below user override", 10000, 12, true, intPtr(10000), intPtr(5000)}, // $60 > $50 self (under the $100 admin override)
{"over cap rejects", 20000, 41, true}, // $205
{"at cap accepts", 20000, 40, false}, // $200 boundary
{"under cap accepts", 20000, 39, false}, // $195
{"zero cap disables", 0, 1000, false}, // explicit off-switch
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Effective cap the handler should enforce, mirroring ConvertCart:
// a non-negative admin override wins over the global; then a present,
// non-negative self-limit folds in via min() (lower-only — 0 means
// "block all", a negative value is ignored).
effectiveCap := tc.capCents
if tc.userCap != nil && *tc.userCap >= 0 {
effectiveCap = *tc.userCap
}
if tc.selfLimit != nil && *tc.selfLimit >= 0 {
if effectiveCap <= 0 || *tc.selfLimit < effectiveCap {
effectiveCap = *tc.selfLimit
}
}
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
user.StripeCustomerID = "cus_test_overlimit"
user.MaxOrderCents = tc.userCap
user.SelfLimitCents = tc.selfLimit
if err := db.Save(&user).Error; err != nil {
t.Fatalf("saved user: %v", err)
}
@@ -820,8 +780,8 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("expectOverCap=%v got code=%d errCode=%q msg=%q", tc.expectOverCap, w.Code, resp.Error.Code, resp.Error.Message)
}
if tc.expectOverCap {
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != effectiveCap {
t.Errorf("limit_cents in details: want %d, got %v", effectiveCap, resp.Error.Details["limit_cents"])
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
}
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"])
@@ -831,22 +791,12 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("audit: expected cart_rejected slog line, got nothing")
}
// The buffer may hold more than one JSON line (e.g. the negative-
// override warning precedes the audit record), so scan for the
// cart_rejected event rather than assuming it's the only line.
var rec map[string]any
for line := range bytes.SplitSeq(bytes.TrimSpace(auditBuf.Bytes()), []byte("\n")) {
var m map[string]any
if err := json.Unmarshal(line, &m); err != nil {
continue
}
if m["event"] == "cart_rejected" {
rec = m
break
}
if err := json.Unmarshal(auditBuf.Bytes(), &rec); err != nil {
t.Fatalf("audit: decode slog record: %v\nraw: %s", err, auditBuf.String())
}
if rec == nil {
t.Fatalf("audit: no cart_rejected slog record found\nraw: %s", 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"])
@@ -854,8 +804,8 @@ func TestConvertCartLimit(t *testing.T) {
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) != effectiveCap {
t.Errorf("audit cap_cents: want %d, got %v", effectiveCap, rec["cap_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,3 +0,0 @@
// Package handlers is where the real work happens, one function per endpoint:
// carts, orders, products, addresses, cards, webhooks, and the rest.
package handlers

View File

@@ -225,23 +225,6 @@ func ReconcileExpiredCards(ctx context.Context) {
}
}
// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed.
// Lazy deletion on read (PayRedirect) is the primary path, this sweep only
// reclaims tokens nobody ever followed so the table stays bounded.
func ReconcilePayRedirects(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
res := db.Where("expires_at < ?", time.Now()).Delete(&models.PayRedirect{})
if res.Error != nil {
reconcileLog().Error("pay_redirects sweep failed", "error", res.Error)
return
}
if res.RowsAffected == 0 {
return
}
reconcileLog().Info("swept expired pay_redirects", "count", res.RowsAffected)
}
// 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) {

View File

@@ -518,81 +518,3 @@ func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt t
}
return order
}
// TestReconcilePayRedirects_DeleteExpiredKeepsLive verifies the predicate
// boundary: a row past ExpiresAt is hard-deleted while a row whose ExpiresAt
// is still in the future survives. Seeding both sides through GORM keeps the
// stored timestamp format identical to the one the sweep binds for time.Now(),
// so the comparison is exercised exactly as it runs in production.
func TestReconcilePayRedirects_DeleteExpiredKeepsLive(t *testing.T) {
testDB := setupTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
expired := models.PayRedirect{
Token: "tok_expired",
URL: "https://example.test/expired",
Purpose: models.RedirectPurposeCheckout3DS,
CreatedAt: now.Add(-20 * time.Minute),
ExpiresAt: now.Add(-10 * time.Minute),
}
live := models.PayRedirect{
Token: "tok_live",
URL: "https://example.test/live",
Purpose: models.RedirectPurposeAddCard,
CreatedAt: now,
ExpiresAt: now.Add(10 * time.Minute),
}
if err := db.Create(&expired).Error; err != nil {
t.Fatalf("seed expired: %v", err)
}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("seed live: %v", err)
}
ReconcilePayRedirects(context.Background())
var remaining []models.PayRedirect
if err := db.Find(&remaining).Error; err != nil {
t.Fatalf("list remaining: %v", err)
}
if len(remaining) != 1 {
t.Fatalf("want 1 row remaining, got %d", len(remaining))
}
if remaining[0].Token != "tok_live" {
t.Fatalf("wrong row survived: want tok_live, got %q", remaining[0].Token)
}
}
func TestReconcilePayRedirects_NoExpiredIsNoOp(t *testing.T) {
testDB := setupTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
live := models.PayRedirect{
Token: "tok_live_only",
URL: "https://example.test/live",
Purpose: models.RedirectPurposeCheckout3DS,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute),
}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("seed live: %v", err)
}
ReconcilePayRedirects(context.Background())
var count int64
if err := db.Model(&models.PayRedirect{}).Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("want 1 row preserved, got %d", count)
}
}

View File

@@ -7,7 +7,6 @@ import (
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
@@ -53,17 +52,8 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" {
// 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)")
// No secret configured — log a loud warning but still process in dev.
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
} else {
sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
@@ -134,20 +124,6 @@ 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,

View File

@@ -222,38 +222,3 @@ 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")
}
}

View File

@@ -25,25 +25,6 @@ import (
const version = "v0.1.0"
// everyTick runs fn on its own goroutine every d until ctx is cancelled.
// Shared by the reconciler sweeps so the ticker + shutdown plumbing lives in
// one place. ReconcileUnshipped intentionally does not use it, it needs a
// staggered warm-up run before its first tick.
func everyTick(ctx context.Context, d time.Duration, fn func(context.Context)) {
go func() {
ticker := time.NewTicker(d)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fn(ctx)
case <-ctx.Done():
return
}
}
}()
}
func main() {
observability.InitLogger()
middleware.SetBuildInfo(version)
@@ -125,8 +106,20 @@ func main() {
}
}()
// Catch orders charged but not recorded due to a crash. Every 5 minutes
everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders)
// Start reconciliation job. runs every 5 minutes to catch any orders
// that were charged but not recorded due to a crash.
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileOrders(ctx)
case <-ctx.Done():
return
}
}
}()
go func() {
select {
@@ -149,18 +142,33 @@ func main() {
// Sweep saved cards whose retention deadline elapsed. Read paths filter
// expired rows from query results, so this sweeper only drives the Stripe
// detach + audit + physical row delete asynchronously. Hourly.
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards)
// detach + audit + physical row delete asynchronously.
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileExpiredCards(ctx)
case <-ctx.Done():
return
}
}
}()
// Flip abandoned 3DS orders to failed. Every 15 minutes.
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
everyTick(ctx, 15*time.Minute, func(ctx context.Context) {
handlers.ReconcileStale3DSOrders(ctx, threshold)
})
// Sweep expired pay_redirects rows so the table stays bounded. Lazy delete
// on read is the primary path; this only reclaims tokens nobody follows.
everyTick(ctx, 10*time.Minute, handlers.ReconcilePayRedirects)
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(ctx, threshold)
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.")

View File

@@ -1,92 +0,0 @@
package main
import (
"context"
"testing"
"time"
)
// TestEveryTick_CallsFnOnTick verifies the helper invokes fn repeatedly on its
// interval. A non-blocking send keeps the spawned goroutine from ever blocking
// on a full channel, and the 1s timeout per tick is generous vs the 1ms
// interval so the test is not timing-flaky.
func TestEveryTick_CallsFnOnTick(t *testing.T) {
ctx := t.Context()
fired := make(chan struct{}, 1)
everyTick(ctx, time.Millisecond, func(context.Context) {
select {
case fired <- struct{}{}:
default:
}
})
for i := range 3 {
select {
case <-fired:
case <-time.After(time.Second):
t.Fatalf("tick %d did not fire within 1s", i+1)
}
}
}
// TestEveryTick_StopsOnContextCancel verifies cancelling ctx stops the loop.
// After the first tick we cancel inside the inter-tick gap, so everyTick's
// select sees only ctx.Done ready (ticker.C is cold) and returns
// deterministically — there is no random pick between two ready cases. We then
// confirm no further tick arrives within a full interval, which a live loop
// could not satisfy. This is the shutdown guarantee the reconcilers depend on.
func TestEveryTick_StopsOnContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
const interval = 50 * time.Millisecond
ticks := make(chan struct{}) // unbuffered: fn blocks until the test receives
everyTick(ctx, interval, func(context.Context) {
ticks <- struct{}{}
})
// First tick proves the loop is running and leaves the goroutine back in
// select with ticker.C cold for the next ~interval.
select {
case <-ticks:
case <-time.After(time.Second):
t.Fatal("everyTick never called fn")
}
cancel()
// Cancel landed in the cold gap, so the loop must have taken ctx.Done and
// returned. A live loop would tick again at ~interval; a stopped one never
// will.
select {
case <-ticks:
t.Fatal("everyTick ticked again after cancel")
case <-time.After(interval + 50*time.Millisecond):
// good: silent past a full interval — the loop returned
}
}
func TestEveryTick_PassesContextToFn(t *testing.T) {
type ctxKey string
const marker ctxKey = "marker"
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), marker, "present"))
defer cancel()
got := make(chan string, 1)
everyTick(ctx, time.Millisecond, func(c context.Context) {
v, _ := c.Value(marker).(string)
select {
case got <- v:
default:
}
})
select {
case v := <-got:
if v != "present" {
t.Fatalf("fn received wrong context: value = %q, want %q", v, "present")
}
case <-time.After(time.Second):
t.Fatalf("fn never ran")
}
}

View File

@@ -1,4 +0,0 @@
// Package middleware holds the stuff that runs on every request before a
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
// recovery net for panics.
package middleware

View File

@@ -1,3 +0,0 @@
// Package routes is the switchboard: it maps every URL to the handler that
// knows what to do with it.
package routes

View File

@@ -53,7 +53,6 @@ func SetupRoutes(
viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
adminUserHandler := handlers.NewAdminUserHandler()
// Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect)
@@ -150,8 +149,6 @@ func SetupRoutes(
r.Use(middleware.RequireAdmin)
r.Patch("/orders/{id}/tracking", orderHandler.UpdateTracking)
r.Post("/orders/{id}/label", orderHandler.PurchaseLabel)
// Set or clear a user's per-user order spend-cap override.
r.Patch("/users/{id}/order-cap", adminUserHandler.SetUserOrderCap)
})
})

View File

@@ -1,3 +0,0 @@
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
// about and detaches them. Run it with -dry-run first if you're nervous.
package main

View File

@@ -1,3 +0,0 @@
// Package api is the client side of the house: it talks to our HTTP backend
// so other Go code doesn't have to hand-roll requests.
package api

View File

@@ -50,7 +50,6 @@ const (
eventTrackingUpdated eventType = "tracking_updated"
eventOrderDelivered eventType = "order_delivered"
eventTrackingMarkedManually eventType = "tracking_marked_manually"
eventUserOrderCapSet eventType = "user_order_cap_set"
)
type event struct {
@@ -381,23 +380,3 @@ func CartRejected(userID uint, totalCents, capCents int) {
}
slog.Info("audit", e.attrs()...)
}
// UserOrderCapSet records an operator changing a user's per-user order-spend
// cap override (User.MaxOrderCents), an admin-gated action. A nil capCents means
// the override was cleared and the user reverts to the global MAX_ORDER_CENTS;
// Status distinguishes "set" from "cleared" so a cap set to 0 (off-switch, which
// attrs() omits because cap_cents == 0) is still legible in the log.
func UserOrderCapSet(actor string, userID uint, capCents *int) {
e := event{
Event: eventUserOrderCapSet,
Actor: actor,
UserID: userID,
}
if capCents == nil {
e.Status = "cleared"
} else {
e.Status = "set"
e.CapCents = *capCents
}
slog.Info("audit", e.attrs()...)
}

View File

@@ -1,3 +0,0 @@
// Package auth sorts out who you are: logging in with an SSH key and minting
// the JWT you carry around afterwards.
package auth

View File

@@ -1,3 +0,0 @@
// Package bring talks to Bring, the Norwegian postal service, for booking
// shipments and printing labels.
package bring

View File

@@ -92,7 +92,6 @@ 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

View File

@@ -1,3 +0,0 @@
// Package config reads everything out of the environment so secrets and
// settings stay out of the source code.
package config

View File

@@ -1,3 +0,0 @@
// Package database opens the SQLite connection, runs the migrations, and
// drops in some seed data so the shop isn't empty on a fresh boot.
package database

View File

@@ -1,3 +0,0 @@
// Package models is the shared vocabulary of the app: coffee, carts, orders,
// addresses, cards, users, and the other nouns everything passes around.
package models

View File

@@ -9,25 +9,16 @@ import (
// User represents a user authenticated via SSH public key
// No passwords - SSH key is the ONLY authentication method (matches terminal.shop exactly)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
SSHKeyFingerprint string `gorm:"uniqueIndex;size:100;not null" json:"ssh_key_fingerprint"` // SHA256 fingerprint - PRIMARY identifier
SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key
Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels)
Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts)
Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key
StripeCustomerID string `gorm:"size:255" json:"-"`
// MaxOrderCents is an optional per-user override of the global MAX_ORDER_CENTS
// spend cap. It may raise OR lower the global cap — both directions are
// intentional (e.g. a vetted corporate buyer gets a higher ceiling; a flagged
// account gets a lower one). States: nil = inherit the global; explicit 0 =
// per-user off-switch (same semantics as the global); >0 = custom ceiling. A
// negative value is treated as "no override" (falls back to global) with a
// warning — see ConvertCart. Operator-set out-of-band; kept out of API payloads.
MaxOrderCents *int `json:"-"`
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
SSHKeyFingerprint string `gorm:"uniqueIndex;size:100;not null" json:"ssh_key_fingerprint"` // SHA256 fingerprint - PRIMARY identifier
SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key
Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels)
Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts)
Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key
StripeCustomerID string `gorm:"size:255" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for the User model

View File

@@ -1,3 +0,0 @@
// Package observability sets up tracing and structured logs so that when
// something breaks at 3am you stand a chance of figuring out why.
package observability

View File

@@ -1,3 +0,0 @@
// Package shipping holds the plain shipping types that the rest of the app
// passes back and forth.
package shipping

View File

@@ -1,2 +0,0 @@
// Package shippo talks to the Shippo API for shipping rates and labels.
package shippo

View File

@@ -1,3 +0,0 @@
// Package stripeclient wires up the Stripe SDK once at startup (with tracing
// baked in) so everyone else can just make calls.
package stripeclient

View File

@@ -1,3 +0,0 @@
// Package tui is the terminal UI you see over SSH: the shop, the cart, your
// account, and the checkout flow, all drawn with Bubbletea.
package tui

View File

@@ -276,7 +276,9 @@ func (m Model) updatePaymentViewport() Model {
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
availH = max(availH, 1)
if availH < 1 {
availH = 1
}
if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.payment.viewport.KeyMap = viewport.KeyMap{}
@@ -301,7 +303,7 @@ func (m Model) PaymentPageView() string {
m.widthContainer,
m.payment.viewport.Height(),
lipgloss.Center, lipgloss.Center,
m.renderPaymentHTTPSView(),
m.renderPaymentHttpsView(),
)
} else if m.payment.view == 0 && m.payment.form == nil {
content = m.RenderCardList()
@@ -372,7 +374,7 @@ func (m Model) RenderCardList() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) renderPaymentHTTPSView() string {
func (m Model) renderPaymentHttpsView() string {
baseStyle := m.theme.TextLabel()
accentStyle := m.theme.TextHighlight()

View File

@@ -1,3 +0,0 @@
// Package qrfefe turns text into a QR code drawn with terminal block
// characters, handy for showing links you can scan right off the screen.
package qrfefe

View File

@@ -24,7 +24,9 @@ const (
// menu(menuW+4) + gap(1) + detail(detailW+4) = contentW = wideW + 4.
func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) {
contentW = m.widthContent
contentW = min(contentW, 74)
if contentW > 74 {
contentW = 74
}
wideW = contentW - 4
if m.size < large {
// Stacked layout: both panels span the full content width.
@@ -64,7 +66,6 @@ func clampLines(s string, n int) string {
// roastMeter renders the strength dots, e.g. ● ● ● ○ ○
func roastMeter(n int) string {
n = max(0, min(n, 5))
on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n))
off := pDim.Render(strings.Repeat("○ ", 5-n))
return strings.TrimRight(on+off, " ")
@@ -217,8 +218,12 @@ func (m Model) shopMoveSelected(previous bool) Model {
} else {
next++
}
next = max(next, 0)
next = min(next, len(m.Coffees)-1)
if next < 0 {
next = 0
}
if next >= len(m.Coffees) {
next = len(m.Coffees) - 1
}
m.shop.selected = next
return m
}

View File

@@ -1,3 +0,0 @@
// Package theme keeps the colors and styles in one spot so the whole UI
// looks like it was built by the same person.
package theme

View File

@@ -1,3 +0,0 @@
// Package utils is the catch-all drawer for tiny HTTP helpers that didn't
// earn a package of their own.
package utils

View File

@@ -1,3 +0,0 @@
// Package validate gives user input a once-over before the rest of the app
// trusts it.
package validate