Compare commits

..

1 Commits

Author SHA1 Message Date
b5ad61eca6 chore/any-pragmatism (#1)
Some checks failed
Deploy / deploy (push) Has been cancelled
Reviewed-on: http://gitea/marcusDenslow/terminalShop/pulls/1
2026-06-06 13:02:02 +00:00
65 changed files with 542 additions and 2570 deletions

View File

@@ -22,15 +22,6 @@ jobs:
key: ${{ secrets.SERVER_SSH_KEY }} key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }} port: ${{ secrets.SERVER_PORT }}
script: | script: |
set -euo pipefail
cd ~/terminalShop cd ~/terminalShop
git fetch origin master git pull
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 docker compose -f docker-compose.prod.yml up -d --build
echo "deploy verified: HEAD=$deployed"

1
.gitignore vendored
View File

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

View File

@@ -71,4 +71,3 @@ uses SQLite by default so there's nothing to spin up.
## inspiration ## 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. [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

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

@@ -46,7 +46,7 @@ func TestGetCards_FiltersExpiredRows(t *testing.T) {
} }
} }
handler := NewCardHandler("") handler := NewCardHandler("", "")
req := authRequest("GET", "/api/v1/cards", nil, user.ID) req := authRequest("GET", "/api/v1/cards", nil, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.GetCards(w, req) handler.GetCards(w, req)
@@ -91,7 +91,7 @@ func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
t.Fatalf("seed card: %v", err) t.Fatalf("seed card: %v", err)
} }
handler := NewCardHandler("") handler := NewCardHandler("", "")
idStr := strconv.FormatUint(uint64(card.ID), 10) idStr := strconv.FormatUint(uint64(card.ID), 10)
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID) req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
routeCtx := chi.NewRouteContext() routeCtx := chi.NewRouteContext()

View File

@@ -67,13 +67,13 @@ func PayRedirect(w http.ResponseWriter, r *http.Request) {
// CardHandler handles saved payment method CRUD. // CardHandler handles saved payment method CRUD.
type CardHandler struct { type CardHandler struct {
appURL string stripeKey string
appURL string
} }
// NewCardHandler creates a new card handler. Stripe credentials are wired // NewCardHandler creates a new card handler with the Stripe secret key.
// once at startup via stripe.Key in api/main.go. func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
func NewCardHandler(appURL string) *CardHandler { return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
return &CardHandler{appURL: appURL}
} }
// GetCards returns all saved cards for the authenticated user. // GetCards returns all saved cards for the authenticated user.
@@ -137,7 +137,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
} }
if card.IsStorageExpired(time.Now()) { if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil { if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return return
} }
@@ -171,6 +171,8 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return return
@@ -232,6 +234,8 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer, creating one if needed. // Ensure the user has a Stripe customer, creating one if needed.
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
@@ -331,6 +335,7 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
// Detach from Stripe (best-effort; don't block deletion on Stripe errors). // Detach from Stripe (best-effort; don't block deletion on Stripe errors).
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" { if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
stripe.Key = h.stripeKey
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}) _, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
} }
@@ -350,10 +355,8 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
// expireStoredCard removes a card whose retention deadline elapsed. // expireStoredCard removes a card whose retention deadline elapsed.
// Local state (cart references + card row) is committed atomically; // Local state (cart references + card row) is committed atomically;
// Stripe detach and audit run only after the local commit so retries // Stripe detach and audit run only after the local commit so retries
// cannot double-fire the audit event. Stripe detach is gated on // cannot double-fire the audit event.
// stripe.Key being non-empty so tests (which never init the SDK key) func expireStoredCard(db *gorm.DB, card *models.Card, stripeKey string) error {
// skip the real network call without an explicit flag.
func expireStoredCard(db *gorm.DB, card *models.Card) error {
if err := db.Transaction(func(tx *gorm.DB) error { if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil { if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil {
return fmt.Errorf("failed to clear expired card from cart: %w", err) return fmt.Errorf("failed to clear expired card from cart: %w", err)
@@ -366,7 +369,8 @@ func expireStoredCard(db *gorm.DB, card *models.Card) error {
return err return err
} }
if stripe.Key != "" && strings.HasPrefix(card.StripePaymentID, "pm_") { if stripeKey != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
stripe.Key = stripeKey
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil { if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err) slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
} }

View File

@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"os" "os"
"testing" "testing"
"time" "time"
@@ -59,7 +58,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
t.Fatalf("seed cart: %v", err) t.Fatalf("seed cart: %v", err)
} }
ReconcileExpiredCards(context.Background()) ReconcileExpiredCards("")
var remaining []models.Card var remaining []models.Card
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil { if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
@@ -118,7 +117,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
} }
} }
ReconcileExpiredCards(context.Background()) ReconcileExpiredCards("")
var count int64 var count int64
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil { if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {

View File

@@ -25,6 +25,7 @@ import (
) )
type CartHandler struct { type CartHandler struct {
stripeKey string
appURL string appURL string
maxOrderCents int maxOrderCents int
} }
@@ -35,10 +36,9 @@ type CartHandler struct {
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free. // audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
const maxAuthIssuances = 4 const maxAuthIssuances = 4
// NewCartHandler creates a new cart handler. Stripe credentials are wired // NewCartHandler creates a new cart handler with the Stripe secret key
// once at startup via stripe.Key in api/main.go. func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler { return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
} }
// getOrCreateCart finds the users cart or creates one. // getOrCreateCart finds the users cart or creates one.
@@ -219,7 +219,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
} }
if card.IsStorageExpired(time.Now()) { if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil { if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return return
} }
@@ -315,7 +315,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if card.IsStorageExpired(time.Now()) { if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired") middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil { if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return return
} }
@@ -330,6 +330,8 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer (shared helper with cards handler). // Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed") middleware.RecordCartConversion("stripe_customer_failed")
@@ -382,7 +384,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if h.maxOrderCents > 0 && total > h.maxOrderCents { if h.maxOrderCents > 0 && total > h.maxOrderCents {
middleware.RecordCartConversion("validation_over_limit") middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, h.maxOrderCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT", utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100), fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{ map[string]any{
@@ -538,10 +539,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}) })
} }
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order // respondRequiresAction handles a PaymentIntent that needs 3DS auth
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md); // The order stays pending. the webhook flips it to paid once the customer
// the payment_intent.succeeded webhook flips it to paid once the customer // completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
// completes the bank-hosted challenge. The TUI polls /api/v1/orders/{id}/status.
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) { func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
db := database.GetDB() db := database.GetDB()
@@ -568,15 +568,13 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
return return
} }
// Persist the paymentIntent id AND set status to requires_action so list // Persist the paymentIntent id AND reset status to pending. The
// UIs can distinguish "charging" from "awaiting customer 3DS". The // payment_intent.payment_failed webhook from the initial off-session decline
// payment_intent.payment_failed webhook from the initial off-session // often arrives before this response and flips the order to failed —
// decline often arrives before this response and flips the order to // override it back so the 3DS-in-flight order shows the correct state.
// failed - override it back so the 3DS-in-flight order shows the
// correct state
if err := db.Model(order).Updates(map[string]any{ if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
"status": models.OrderStatusRequiresAction, "status": models.OrderStatusPending,
}).Error; err != nil { }).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return return
@@ -660,14 +658,10 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
// Retryable source states: // Only pending (initial attempt mid-flight) and failed (webhook flipped it
// pending - initial attempt mid-flight (pre-PI confirm page) // after a non-recoverable error) are retryable. paid, refundedn shipped,
// requires_action - customer abandoned or failed the 3DS challenge // delivered, cancelled are terminal - refuse
// failed - webhook flipped after a non-recoverable error if order.Status != models.OrderStatusPending && order.Status != models.OrderStatusFailed {
// terminal states (paid, shipped, delivered, cancelled, refunded) refuse.
if order.Status != models.OrderStatusPending &&
order.Status != models.OrderStatusRequiresAction &&
order.Status != models.OrderStatusFailed {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil) utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
return return
} }
@@ -705,7 +699,7 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) { if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil { if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return return
} }
@@ -713,6 +707,8 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
stripeStart := time.Now() stripeStart := time.Now()
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{ confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(card.StripePaymentID), PaymentMethod: stripe.String(card.StripePaymentID),

View File

@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -12,7 +11,6 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit" "terminalShop/pkg/audit"
@@ -70,7 +68,7 @@ func TestGetCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
req := authRequest("GET", "/api/v1/cart", nil, user.ID) req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -109,7 +107,7 @@ func TestSetItemAndGetCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item (CoffeeID=1, Quantity=2) // Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
@@ -165,7 +163,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item // Add item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
@@ -210,7 +208,7 @@ func TestSetItemRemove(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item // Add item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
@@ -249,7 +247,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1}) body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -266,7 +264,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"quantity": 1}) body, _ := json.Marshal(map[string]any{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -283,7 +281,7 @@ func TestClearCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add two items // Add two items
for _, coffeeID := range []uint{1, 2} { for _, coffeeID := range []uint{1, 2} {
@@ -339,7 +337,7 @@ func TestSetAddress(t *testing.T) {
} }
db.Create(&addr) db.Create(&addr)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"address_id": addr.ID}) body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -393,7 +391,7 @@ func TestSetAddressWrongUser(t *testing.T) {
} }
db.Create(&addr) db.Create(&addr)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"address_id": addr.ID}) body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -421,7 +419,7 @@ func TestSetCard(t *testing.T) {
} }
db.Create(&card) db.Create(&card)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID}) body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -473,7 +471,7 @@ func TestSetCardWrongUser(t *testing.T) {
} }
db.Create(&card) db.Create(&card)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID}) body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -490,7 +488,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item but no address or card // Add item but no address or card
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
@@ -513,7 +511,7 @@ func TestConvertCartMissingCard(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
db := database.GetDB() db := database.GetDB()
// Add item and address // Add item and address
@@ -544,7 +542,7 @@ func TestConvertCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Empty cart convert // Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -561,7 +559,7 @@ func TestMultipleItems(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add three different items // Add three different items
for i := uint(1); i <= 3; i++ { for i := uint(1); i <= 3; i++ {
@@ -610,7 +608,7 @@ func TestCartIsolation(t *testing.T) {
} }
db.Create(&user2) db.Create(&user2)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
r := chi.NewRouter() r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem) r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart) r.Get("/cart", handler.GetCart)
@@ -719,7 +717,7 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("pin coffee price: %v", err) t.Fatalf("pin coffee price: %v", err)
} }
handler := NewCartHandler("", tc.capCents) handler := NewCartHandler("", "", tc.capCents)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity}) body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -747,19 +745,6 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String()) 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 // Convert
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
@@ -786,27 +771,6 @@ func TestConvertCartLimit(t *testing.T) {
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice { 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"]) 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"])
}
} }
}) })
} }
@@ -830,7 +794,7 @@ func TestSetCard_ExpiredReturns410(t *testing.T) {
t.Fatalf("seed card: %v", err) t.Fatalf("seed card: %v", err)
} }
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID}) body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -871,7 +835,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/vart/item", body, user.ID) req := authRequest("PUT", "/api/v1/vart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
NewCartHandler("", 0).SetItem(w, req) NewCartHandler("", "", 0).SetItem(w, req)
addr := models.Address{ addr := models.Address{
UserID: user.ID, Name: "Test", Street: "123 St", UserID: user.ID, Name: "Test", Street: "123 St",
@@ -881,7 +845,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
NewCartHandler("", 0).SetAddress(w, req) NewCartHandler("", "", 0).SetAddress(w, req)
past := time.Now().Add(-1 * time.Hour) past := time.Now().Add(-1 * time.Hour)
card := models.Card{ card := models.Card{
@@ -897,7 +861,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
NewCartHandler("", 0).ConvertCart(w, req) NewCartHandler("", "", 0).ConvertCart(w, req)
if w.Code != http.StatusGone { if w.Code != http.StatusGone {
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String()) t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
@@ -919,9 +883,8 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
// succeeded) hit the live Stripe API and aren't covered here — see // succeeded) hit the live Stripe API and aren't covered here — see
// sca-psd2-compliance.md "Test plan" for manual coverage with the // sca-psd2-compliance.md "Test plan" for manual coverage with the
// 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies: // 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies:
// NewCartHandler takes 2 args (appURL, maxOrderCents) and the seeded user // NewCartHandler takes 3 args and the seeded user must have a non-empty
// must have a non-empty StripeCustomerID so the Convert/Retry paths stay // StripeCustomerID so the Convert/Retry paths stay hermetic.
// hermetic.
func TestRetryAuth(t *testing.T) { func TestRetryAuth(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -956,12 +919,6 @@ func TestRetryAuth(t *testing.T) {
seedPayment: "pi_test_refunded", seedEvents: 1, seedPayment: "pi_test_refunded", seedEvents: 1,
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE", wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
}, },
{
name: "requires_action honors retry cap",
ownedByUser: true, seedStatus: models.OrderStatusRequiresAction,
seedPayment: "pi_test_req_action_cap", seedEvents: maxAuthIssuances,
wantStatus: http.StatusTooManyRequests, wantErrCode: "RETRY_LIMIT",
},
{ {
name: "retry cap reached returns 429", name: "retry cap reached returns 429",
ownedByUser: true, seedStatus: models.OrderStatusFailed, ownedByUser: true, seedStatus: models.OrderStatusFailed,
@@ -1028,7 +985,7 @@ func TestRetryAuth(t *testing.T) {
} }
} }
handler := NewCartHandler("http://localhost", 0) handler := NewCartHandler("", "http://localhost", 0)
req := orderRequest("POST", req := orderRequest("POST",
fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID)) fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1056,7 +1013,7 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("http://localhost", 0) handler := NewCartHandler("", "http://localhost", 0)
req := orderRequest("POST", "/api/v1/orders/9999/retry-auth", nil, user.ID, "9999") req := orderRequest("POST", "/api/v1/orders/9999/retry-auth", nil, user.ID, "9999")
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.RetryAuth(w, req) handler.RetryAuth(w, req)
@@ -1065,137 +1022,3 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String()) t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String())
} }
} }
func TestRespondRequiresAction_SetStatusRequiresAction(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_req_action",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID,
Status: models.OrderStatusPending,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_action_set",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test/test_3ds",
},
},
}
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
OrderID uint `json:"order_id"`
PaymentIntentID string `json:"payment_intent_id"`
Status string `json:"status"`
RedirectURL string `json:"redirect_url"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Data.Status != "requires_action" {
t.Fatalf("response status: %q, got %q", "requires_action", resp.Data.Status)
}
if resp.Data.RedirectURL == "" {
t.Fatalf("expected redirect url, got empty")
}
if resp.Data.PaymentIntentID != pi.ID {
t.Fatalf("payment intent id: want %q, got %q", pi.ID, resp.Data.PaymentIntentID)
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("order.Status: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
if reloaded.StripePaymentID != pi.ID {
t.Fatalf("order.StripePaymentID: want %q, got %q", pi.ID, reloaded.StripePaymentID)
}
}
// TestRespondRequiresAction_OverridesFailedFromWebhookRace simulates the race
// described in sca-psd2-compliance.md note #3 / caveat #4: the
// payment_intent.payment_failed webhook arrives BEFORE respondRequiresAction
// returns and flips the row to OrderStatusFailed. The helper must override
// that back to OrderStatusRequiresAction so the in-flight 3DS state surfaces
// correctly to the TUI.
func TestRespondRequiresAction_OverridesFailedFromWebhookRace(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_race",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID,
Status: models.OrderStatusFailed,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_race",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test_race",
},
},
}
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("override failed: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
}

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

@@ -28,6 +28,7 @@ import (
) )
type OrderHandler struct { type OrderHandler struct {
stripeKey string
bringAPIUID string bringAPIUID string
bringAPIKey string bringAPIKey string
bringCustomerNumber string bringCustomerNumber string
@@ -43,10 +44,9 @@ type refundRequestPayload struct {
// repeated submissions from spamming the operator's Slack channel. // repeated submissions from spamming the operator's Slack channel.
const refundRequestCooldown = 15 * time.Minute const refundRequestCooldown = 15 * time.Minute
// NewOrderHandler creates an order handler. Stripe credentials are wired func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
// once at startup via stripe.Key in api/main.go.
func NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
return &OrderHandler{ return &OrderHandler{
stripeKey: stripeSecretKey,
bringAPIUID: bringAPIUID, bringAPIUID: bringAPIUID,
bringAPIKey: bringAPIKey, bringAPIKey: bringAPIKey,
bringCustomerNumber: bringCustomerNumber, bringCustomerNumber: bringCustomerNumber,
@@ -200,6 +200,8 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
rf, err := refund.New(&stripe.RefundParams{ rf, err := refund.New(&stripe.RefundParams{
PaymentIntent: stripe.String(order.StripePaymentID), PaymentIntent: stripe.String(order.StripePaymentID),
}) })

View File

@@ -60,7 +60,7 @@ func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Contex
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) { func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
user := setupOrderTestDB(t) user := setupOrderTestDB(t)
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Other","message":" "}`) body := []byte(`{"reason":"Other","message":" "}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1") req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -100,7 +100,7 @@ func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
t.Fatalf("failed to create order: %v", err) t.Fatalf("failed to create order: %v", err)
} }
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`) body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -142,7 +142,7 @@ func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
t.Fatalf("failed to create order: %v", err) t.Fatalf("failed to create order: %v", err)
} }
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`) body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder() w := httptest.NewRecorder()

View File

@@ -1,13 +1,10 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/notify" "terminalShop/pkg/notify"
@@ -17,19 +14,15 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// paymentIntentGetFn lets tests stub the Stripe Get call without spinning
// up an httptest backend. Production callers go straight through to the
// real paymentintent.Get.
var paymentIntentGetFn = paymentintent.Get
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") } func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes // ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes // 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 // the case where the server crashed after charging but before the DB transaction
// completed. // completed.
func ReconcileOrders(ctx context.Context) { func ReconcileOrders(stripeKey string) {
db := database.GetDB().WithContext(ctx) stripe.Key = stripeKey
db := database.GetDB()
var orders []models.Order var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute) cutoff := time.Now().Add(-10 * time.Minute)
@@ -43,14 +36,9 @@ func ReconcileOrders(ctx context.Context) {
reconcileLog().Info("checking pending orders", "count", len(orders)) reconcileLog().Info("checking pending orders", "count", len(orders))
for _, order := range orders { for _, order := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
return
}
params := &stripe.PaymentIntentSearchParams{ params := &stripe.PaymentIntentSearchParams{
SearchParams: stripe.SearchParams{ SearchParams: stripe.SearchParams{
Context: ctx, Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
}, },
} }
@@ -89,117 +77,12 @@ func ReconcileOrders(ctx context.Context) {
} }
} }
// ReconcileStale3DSOrders flips requires_action orders that have been
// awaiting the customer's 3DS challenge longer than threshold to failed,
// after re-checking the PaymentIntent with Stripe so a late
// payment_intent.succeeded webhook is respected. Deliberately separate
// 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(ctx context.Context, threshold time.Duration) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order
// Predicate keys off updated_at, not created_at: respondRequiresAction
// and RetryAuth both bump updated_at via GORM auto-stamping, so every
// customer-driven retry extends the grace window. Intentional - sweeping
// a row the customer just re-armed would race the new challenge.
cutoff := time.Now().Add(-threshold)
if err := db.Where("status = ? AND updated_at < ?",
models.OrderStatusRequiresAction, cutoff).Find(&orders).Error; err != nil {
reconcileLog().Error("stale 3ds scan failed", "error", err)
return
}
if len(orders) == 0 {
return
}
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, 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 {
case stripe.PaymentIntentStatusSucceeded:
// Stripe says paid but our row still says requires_action, the
// payment_intent.succeeded webhook has not landed yet (or was
// dropped). Defer to handlePaymentIntentsucceeded; it owns the
// paid transition + cart cleanup atomically. The next sweep
// 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,
stripe.PaymentIntentStatusCanceled:
flipStale3DSToFailed(db, order, string(actual.Status))
default:
reconcileLog().Warn("unrecognized stripe status; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("unrecognized_status")
}
}
}
// flipStale3DSToFailed transitions a single stale requires_action row
// to failed. The UPDATE is conditional on status = requires_action so a
// payment_intent.succeeded webhook that landed between the stripe get and
// this write (caveat #20 race) wins and we silently no-op
func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string) {
// Map form so the single-field UPDATE is explicit at the call site.
// GORM still auto-stamps updated_at; the row exits the sweep predicate
// naturally on the next tick because status no longer matches anyway.
res := db.Model(&models.Order{}).
Where("id = ? AND status = ?", order.ID, models.OrderStatusRequiresAction).
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. // ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
// Read paths filter out expired rows so users never see them; this job drives // 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 // the Stripe detach + audit + physical row delete out of band so request
// handlers never block on Stripe API calls during a card sweep. // handlers never block on Stripe API calls during a card sweep.
func ReconcileExpiredCards(ctx context.Context) { func ReconcileExpiredCards(stripeKey string) {
db := database.GetDB().WithContext(ctx) db := database.GetDB()
var cards []models.Card var cards []models.Card
if err := db.Where( if err := db.Where(
@@ -215,37 +98,16 @@ func ReconcileExpiredCards(ctx context.Context) {
reconcileLog().Info("expiring inactive saved cards", "count", len(cards)) reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
for i := range cards { for i := range cards {
if err := ctx.Err(); err != nil { if err := expireStoredCard(db, &cards[i], stripeKey); 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) reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
} }
} }
} }
// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed. // ReconcileUnshipped finds paid order orlder than 24h with no tracking
// Lazy deletion on read (PayRedirect) is the primary path, this sweep only // number and posts single Slack reminder. Pure read + notif
// reclaims tokens nobody ever followed so the table stays bounded. func ReconcileUnshipped() {
func ReconcilePayRedirects(ctx context.Context) { db := database.GetDB()
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) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour) cutoff := time.Now().Add(-24 * time.Hour)

View File

@@ -1,598 +0,0 @@
package handlers
import (
"context"
"fmt"
"os"
"testing"
"time"
"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
// and Stripe-Get branching: a row past the threshold whose PaymentIntent
// Stripe still reports as requires_payment_method (the customer never
// completed the challenge) transitions to failed and emits the new
// order_3ds_abandoned audit row.
func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(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)
order := seedRequiresActionOrder(t, user.ID, "pi_stale_abandoned",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
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 {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
var events []models.OrderEvent
if err := db.Where("order_id = ? AND type = ?",
order.ID, audit.EventOrder3DSAbandoned).Find(&events).Error; err != nil {
t.Fatalf("list events: %v", err)
}
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
// excludes rows still within the grace window. A fresh requires_action row
// must not even reach the Stripe Get call.
func TestReconcileStale3DSOrders_SkipsRecent(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)
order := seedRequiresActionOrder(t, user.ID, "pi_fresh_3ds",
time.Now().Add(-5*time.Minute))
called := false
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
called = true
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for in-window row")
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("status changed unexpectedly: %q", reloaded.Status)
}
}
// TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded covers the
// webhook-race guard from caveat #20. If Stripe-side already says succeeded,
// the sweep must defer to handlePaymentIntentSucceeded rather than overwrite
// the authoritative end state.
func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(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)
order := seedRequiresActionOrder(t, user.ID, "pi_succeeded_late_webhook",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusSucceeded,
}, nil
}
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 {
t.Fatalf("reload: %v", err)
}
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
// UPDATE. If the row flipped to paid between the Stripe Get and the flip,
// the WHERE status = requires_action clause prevents clobber.
func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(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)
order := seedRequiresActionOrder(t, user.ID, "pi_race_paid",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
db.Model(&models.Order{}).Where("id = ?", order.ID).
Update("status", models.OrderStatusPaid)
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
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 {
t.Fatalf("reload: %v", err)
}
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:
// Stripe-terminal-canceled maps to OrderStatusFailed (matches RetryAuth's
// existing precedent — see api/handlers/cart.go RetryAuth canceled branch).
func TestReconcileStale3DSOrders_FlipsCanceledPI(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)
order := seedRequiresActionOrder(t, user.ID, "pi_canceled_stale",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusCanceled,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("canceled PI must flip to failed; got %q", reloaded.Status)
}
}
// TestReconcileStale3DSOrders_DoesNotConsumeRetryCap regression-tests the
// caveat #17 decoupling: the new sweep emits EventOrder3DSAbandoned, never
// EventOrderRequiresAction, so a swept-then-stuck order does not silently
// shrink the retry ceiling for any future user-driven retry attempts.
func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(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)
order := seedRequiresActionOrder(t, user.ID, "pi_decoupled_cap",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var retryCount int64
if err := db.Model(&models.OrderEvent{}).
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
Count(&retryCount).Error; err != nil {
t.Fatalf("count retry events: %v", err)
}
if retryCount != 0 {
t.Fatalf("sweep must not emit EventOrderRequiresAction; got %d", retryCount)
}
}
// TestReconcileStale3DSOrders_SkipsTransitioningStatuses covers the
// processing and requires_capture branches: Stripe still owns the PI's
// next move, so the sweep must not flip even past the threshold.
func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
cases := []struct {
name string
status stripe.PaymentIntentStatus
}{
{"processing", stripe.PaymentIntentStatusProcessing},
{"requires_capture", stripe.PaymentIntentStatusRequiresCapture},
}
for _, tc := range cases {
t.Run(tc.name, func(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)
order := seedRequiresActionOrder(t, user.ID,
"pi_transitioning_"+tc.name, time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
}
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 {
t.Fatalf("reload: %v", err)
}
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)
}
})
}
}
// TestReconcileStale3DSOrders_SkipsUnknownStatus covers the switch's default
// arm: an unrecognized Stripe status logs and skips rather than guessing a
// transition. Defends against new PI statuses appearing in future SDKs.
func TestReconcileStale3DSOrders_SkipsUnknownStatus(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)
order := seedRequiresActionOrder(t, user.ID, "pi_unknown_status",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatus("some_new_future_status"),
}, nil
}
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 {
t.Fatalf("reload: %v", err)
}
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
// failure logs and leaves the row alone. Transient network errors must not
// look like an abandoned 3DS.
func TestReconcileStale3DSOrders_SkipsOnStripeError(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)
order := seedRequiresActionOrder(t, user.ID, "pi_stripe_error",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return nil, fmt.Errorf("simulated stripe outage")
}
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 {
t.Fatalf("reload: %v", err)
}
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
// defensive empty-PI short-circuit: even past the threshold, a row missing
// stripe_payment_id must not reach paymentintent.Get (which would 4xx).
func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(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)
order := seedRequiresActionOrder(t, user.ID, "pi_will_be_blanked",
time.Now().Add(-1*time.Hour))
if err := db.Model(&order).UpdateColumn("stripe_payment_id", "").Error; err != nil {
t.Fatalf("blank PI id: %v", err)
}
called := false
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
called = true
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
}
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")
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
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
// state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of
// UpdatedAt on Create does not bump the row out of the sweep predicate.
func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt time.Time) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: "pm_test_" + piID,
Last4: "4242", Brand: "Visa",
ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: userID, CardID: card.ID,
StripePaymentID: piID,
Status: models.OrderStatusRequiresAction,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
if err := db.Model(&order).UpdateColumn("updated_at", updatedAt).Error; err != nil {
t.Fatalf("backdate updated_at: %v", err)
}
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" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@@ -29,13 +28,13 @@ func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
// WebhookHandler handles inbound Stripe webhook events. // WebhookHandler handles inbound Stripe webhook events.
type WebhookHandler struct { type WebhookHandler struct {
webhookSecret string webhookSecret string
stripeKey string
shippoSecret string shippoSecret string
} }
// NewWebhookHandler creates a new webhook handler. Stripe credentials are // NewWebhookHandler creates a new webhook handler.
// wired once at startup via stripe.Key in api/main.go. func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler {
func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler { return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret}
return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret}
} }
// HandleStripe validates the Stripe signature and routes the event. // HandleStripe validates the Stripe signature and routes the event.
@@ -53,17 +52,8 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Verify the Stripe signature. Reject anything that doesn't match. // Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" { if h.webhookSecret == "" {
// Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET // No secret configured — log a loud warning but still process in dev.
// in production, so the server should never boot into this state; this is webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
// Slack handlers, which already refuse to run without their secret.
if os.Getenv("ENVIRONMENT") == "production" {
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
return
}
// Development only; warn loudly but still process so local testing works.
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
} else { } else {
sig := r.Header.Get("Stripe-Signature") sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{ if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
@@ -134,20 +124,6 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return 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 { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{ if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
@@ -208,7 +184,7 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
} }
db := database.GetDB().WithContext(ctx) db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction). if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed).Error; err != nil { Update("status", models.OrderStatusFailed).Error; err != nil {
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err) webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
} }
@@ -236,6 +212,8 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
return return
} }
stripe.Key = h.stripeKey
si, err := setupintent.Get(sess.SetupIntent.ID, nil) si, err := setupintent.Get(sess.SetupIntent.ID, nil)
if err != nil { if err != nil {
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err) webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)

View File

@@ -1,259 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
// a PaymentIntent referencing the given order via metadata. errCode is set on
// last_payment_error to drive the authentication_required short-circuit
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
t.Helper()
pi := map[string]any{
"id": piID,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
}
if errCode != "" {
pi["last_payment_error"] = map[string]any{"code": errCode}
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
return stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
}
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: userID, CardID: card.ID,
StripePaymentID: piID,
Status: status,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
return order
}
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
// widened predicate (IN (pending, requires_action)) catches a row in the new
// requires_action state. Without this case, a card_declined webhook arriving
// after respondRequiresAction wrote requires_action would leave the row
// stuck forever.
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_failed")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
// still fires when the row is in requires_action and the error code is
// authentication_required. Without the short-circuit, the recovery flow
// would race the webhook and surface a momentarily-failed state to the TUI.
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_authreq")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
// pending -> failed transition against a regression where the widened
// predicate accidentally dropped pending from the source set.
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
// the predicate: a paid row must NEVER be flipped to failed by a stale
// payment_failed webhook. Without this guard, a future maintainer could
// widen the IN clause to include paid (e.g. for some refund-retry flow)
// and silently break idempotency.
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
// short-circuit so the test file exercises every early-return branch in
// handlePaymentIntentFailed touched by the predicate work.
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
testDB, _ := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
pi := map[string]any{"id": "pi_test_no_meta"}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentFailed(context.Background(), evt) // must not panic
}
func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid")
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"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("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("status: want %q, got %q", models.OrderStatusPaid, reloaded.Status)
}
if reloaded.StripePaymentID != order.StripePaymentID {
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

@@ -19,31 +19,10 @@ import (
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/observability" "terminalShop/pkg/observability"
"terminalShop/pkg/stripeclient" "terminalShop/pkg/stripeclient"
"github.com/stripe/stripe-go/v78"
) )
const version = "v0.1.0" 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() { func main() {
observability.InitLogger() observability.InitLogger()
middleware.SetBuildInfo(version) middleware.SetBuildInfo(version)
@@ -70,12 +49,6 @@ func main() {
} }
audit.SetDB(db) audit.SetDB(db)
// wire the stripe SDK's package-level key once at startup so concurrent
// reconcilers and the request path don't race on stripe.Key writes.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripeSecretKey
// Seed database with initial data // Seed database with initial data
if err := database.Seed(db); err != nil { if err := database.Seed(db); err != nil {
log.Fatalf("Failed to seed database: %v", err) log.Fatalf("Failed to seed database: %v", err)
@@ -98,7 +71,7 @@ func main() {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes // Setup routes
router := routes.SetupRoutes(version, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents) router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents)
// Create HTTP server // Create HTTP server
server := &http.Server{ server := &http.Server{
@@ -125,22 +98,30 @@ func main() {
} }
}() }()
// Catch orders charged but not recorded due to a crash. Every 5 minutes // Start reconciliation job. runs every 5 minutes to catch any orders
everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders) // 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(cfg.StripeSecretKey)
case <-ctx.Done():
return
}
}
}()
go func() { go func() {
select { time.Sleep(30 * time.Second)
case <-time.After(30 * time.Second): handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
handlers.ReconcileUnshipped(ctx)
ticker := time.NewTicker(24 * time.Hour) ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
handlers.ReconcileUnshipped(ctx) handlers.ReconcileUnshipped()
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -149,18 +130,19 @@ func main() {
// Sweep saved cards whose retention deadline elapsed. Read paths filter // Sweep saved cards whose retention deadline elapsed. Read paths filter
// expired rows from query results, so this sweeper only drives the Stripe // expired rows from query results, so this sweeper only drives the Stripe
// detach + audit + physical row delete asynchronously. Hourly. // detach + audit + physical row delete asynchronously.
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards) go func() {
ticker := time.NewTicker(1 * time.Hour)
// Flip abandoned 3DS orders to failed. Every 15 minutes. defer ticker.Stop()
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute for {
everyTick(ctx, 15*time.Minute, func(ctx context.Context) { select {
handlers.ReconcileStale3DSOrders(ctx, threshold) case <-ticker.C:
}) handlers.ReconcileExpiredCards(cfg.StripeSecretKey)
case <-ctx.Done():
// Sweep expired pay_redirects rows so the table stays bounded. Lazy delete return
// on read is the primary path; this only reclaims tokens nobody follows. }
everyTick(ctx, 10*time.Minute, handlers.ReconcilePayRedirects) }
}()
log.Println("API server started. Press Ctrl+C to shutdown.") log.Println("API server started. Press Ctrl+C to shutdown.")
@@ -170,10 +152,10 @@ func main() {
log.Println("Server is shutting down...") log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout // Graceful shutdown with 30 second timeout
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel() defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil { if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err) log.Fatalf("Server forced to shutdown: %v", err)
} }

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

@@ -70,14 +70,6 @@ var (
[]string{"outcome"}, []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( orderValueDollars = promauto.NewHistogram(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
Name: "order_value_dollars", Name: "order_value_dollars",
@@ -181,19 +173,6 @@ func ObserveOrderValueCents(cents int) {
orderValueDollars.Observe(float64(cents) / 100.0) 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. // ObserveStripeRequest records latency of a Stripe API call.
func ObserveStripeRequest(op, outcome string, durSec float64) { func ObserveStripeRequest(op, outcome string, durSec float64) {
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec) stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)

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

@@ -16,6 +16,7 @@ import (
// SetupRoutes configures all API routes // SetupRoutes configures all API routes
func SetupRoutes( func SetupRoutes(
version string, version string,
stripeSecretKey string,
stripeWebhookSecret string, stripeWebhookSecret string,
jwtManager *auth.JWTManager, jwtManager *auth.JWTManager,
authFingerprintKey string, authFingerprintKey string,
@@ -46,12 +47,12 @@ func SetupRoutes(
healthHandler := handlers.NewHealthHandler(version) healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler() productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(appURL, maxOrderCents) cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents)
cardHandler := handlers.NewCardHandler(appURL) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
orderHandler := handlers.NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler() viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret) webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret) slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
// Short payment redirect and success page — no auth required. // Short payment redirect and success page — no auth required.

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

1
go.mod
View File

@@ -81,7 +81,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // 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/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect

14
main.go
View File

@@ -15,7 +15,6 @@ import (
"charm.land/wish/v2/bubbletea" "charm.land/wish/v2/bubbletea"
"charm.land/wish/v2/logging" "charm.land/wish/v2/logging"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"terminalShop/pkg/config" "terminalShop/pkg/config"
@@ -32,6 +31,7 @@ func listenHost() string {
var ( var (
apiURL string apiURL string
authFingerprintKey string authFingerprintKey string
stripePublicKey string
) )
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -43,7 +43,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey) pubKeyStr = auth.FormatSSHPublicKey(pubKey)
} }
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey) m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, nil return m, nil
} }
@@ -57,15 +57,7 @@ func main() {
// Set API URL and auth key from config for TUI connections // Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL apiURL = cfg.APIURL
authFingerprintKey = cfg.AuthFingerprintKey authFingerprintKey = cfg.AuthFingerprintKey
stripePublicKey = cfg.StripePublicKey
// Wire the publishable key into stripe-go's package-level global once.
// TUI uses the legacy Tokens API (stripetoken.New) which accepts the
// publishable key; per-session writes would race across concurrent SSH
// sessions even though they'd all write the same value.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripePublicKey
log.Printf("TUI will connect to API at: %s", apiURL) log.Printf("TUI will connect to API at: %s", apiURL)
// Create SSH server // Create SSH server

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

@@ -14,22 +14,10 @@ import (
) )
// EventOrderRequiresAction is the audit event-type string emitted whenever a // EventOrderRequiresAction is the audit event-type string emitted whenever a
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler // PaymentIntent is pushed into requires_action. explored so cart-flow handler
// can count retry attempts without hardcoding the string literal. // can count retry attempts without hardcoding the string literal.
const EventOrderRequiresAction = "order_requires_action" const EventOrderRequiresAction = "order_requires_action"
// EventOrder3DSAbandoned is the audit event-type string emitted by the
// abandoned-3DS sweep when it flips a stale requires_action row to failed.
// Distinct from EventOrderRequiresAction (caveat #17) so the retry-cap count
// 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 type eventType string
const ( const (
@@ -41,8 +29,6 @@ const (
eventOrderShipped eventType = "order_shipped" eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed" eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = EventOrderRequiresAction eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
eventCartRejected eventType = EventCartRejected
eventOrderRefunded eventType = "order_refunded" eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical" eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased" eventLabelPurchased eventType = "label_purchased"
@@ -53,21 +39,19 @@ const (
) )
type event struct { type event struct {
Event eventType Event eventType
UserID uint UserID uint
OrderID uint OrderID uint
CardID uint CardID uint
Amount int Amount int
StripeID string StripeID string
CardLast4 string CardLast4 string
CardBrand string CardBrand string
Carrier string Carrier string
Tracking string Tracking string
Status string Status string
Error string Error string
Actor string Actor string
TotalCents int
CapCents int
} }
func (e event) attrs() []any { func (e event) attrs() []any {
@@ -108,12 +92,6 @@ func (e event) attrs() []any {
if e.Actor != "" { if e.Actor != "" {
a = append(a, "actor", 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 return a
} }
@@ -350,33 +328,3 @@ func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
slog.Info("audit", e.attrs()...) slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRequiresAction, e) persist(orderID, eventOrderRequiresAction, e)
} }
// Order3DSAbandoned records the abandoned-3DS sweep flipping a stale
// requires_action row to failed because Stripe still reports the underlying
// PaymentIntent as outstanding past the sweep threshold.
func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrder3DSAbandoned,
UserID: userID,
OrderID: orderID,
StripeID: paymentIntentID,
}
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()...)
}

View File

@@ -1,41 +0,0 @@
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

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

@@ -3,7 +3,6 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
@@ -14,26 +13,25 @@ import (
// Config holds all application configuration // Config holds all application configuration
type Config struct { type Config struct {
APIPort string APIPort string
APIURL string APIURL string
AppURL string AppURL string
SSHPort string SSHPort string
Environment string Environment string
DatabaseURL string DatabaseURL string
JWTSecret string JWTSecret string
StripeSecretKey string StripeSecretKey string
StripePublicKey string StripePublicKey string
StripeWebhookSecret string StripeWebhookSecret string
ShippoAPIKey string ShippoAPIKey string
AuthFingerprintKey string AuthFingerprintKey string
BringAPIUID string BringAPIUID string
BringAPIKey string BringAPIKey string
BringCustomerNumber string BringCustomerNumber string
ShippoWebhookSecret string ShippoWebhookSecret string
SlackSigningSecret string SlackSigningSecret string
AdminAPIKey string AdminAPIKey string
MaxOrderCents int MaxOrderCents int
Abandoned3DSThresholdMinutes int
} }
// Load reads configuration from environment variables and validates required secrets. // Load reads configuration from environment variables and validates required secrets.
@@ -52,26 +50,25 @@ func Load() (*Config, error) {
apiPort := getEnvOrDefault("API_PORT", "8080") apiPort := getEnvOrDefault("API_PORT", "8080")
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort)) apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
cfg := &Config{ cfg := &Config{
APIPort: apiPort, APIPort: apiPort,
APIURL: apiURL, APIURL: apiURL,
AppURL: getEnvOrDefault("APP_URL", apiURL), AppURL: getEnvOrDefault("APP_URL", apiURL),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"), SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"), Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"), DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"), StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"), ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"), AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"), BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"), BringAPIKey: os.Getenv("BRING_API_KEY"),
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"), BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"), ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"), AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
MaxOrderCents: loadMaxOrderCents(), MaxOrderCents: loadMaxOrderCents(),
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
} }
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
@@ -92,7 +89,6 @@ func (c *Config) validate() error {
{"JWT_SECRET", c.JWTSecret}, {"JWT_SECRET", c.JWTSecret},
{"STRIPE_SECRET_KEY", c.StripeSecretKey}, {"STRIPE_SECRET_KEY", c.StripeSecretKey},
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey}, {"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
} }
var missing []string var missing []string
@@ -138,13 +134,11 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue return defaultValue
} }
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the // loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the // the cap. Garbage or negative values fall back to the default with a warning
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or // so an operator typo cannot silently disable a fraud control.
// negative values fall back to the default with a warning so an operator
// typo cannot silently disable a fraud control.
func loadMaxOrderCents() int { func loadMaxOrderCents() int {
const defaultCap = 50000 const defaultCap = 20000
raw := os.Getenv("MAX_ORDER_CENTS") raw := os.Getenv("MAX_ORDER_CENTS")
if raw == "" { if raw == "" {
return defaultCap return defaultCap
@@ -156,26 +150,3 @@ func loadMaxOrderCents() int {
} }
return n return n
} }
// loadAbandoned3DSThresholdMinutes read ABANDONED_3DS_THRESHOLD_MINUTES.
// The abandoned-3DS sweep only flips requires_action rows whose updated_at iconfigs
// older than this many minutes. Default 30 min. Values below 1 fall back to
// the default so a typo cannot collapse the grace window the TUI polls inside.
func loadAbandoned3DSThresholdMinutes() int {
const defaultMinutes = 30
raw := os.Getenv("ABANDONED_3DS_THRESHOLD_MINUTES")
if raw == "" {
return defaultMinutes
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
slog.Warn("invalid ABANDONED_3DS_THRESHOLD_MINUTES, falling back",
"raw", raw, "default", defaultMinutes)
return defaultMinutes
}
if n < 10 {
slog.Warn("ABANDONED_3DS_THRESHOLD_MINUTES below 10 may race in-flight 3ds challenges",
"value", n)
}
return n
}

View File

@@ -1,35 +1,21 @@
package config package config
import ( import (
"io"
"log/slog"
"os"
"testing" "testing"
) )
// TestMain silences the package's slog warnings so the negative/garbage
// env-var cases plus the sub-10 valid-minimum case don't spam test output.
// The actual fall-back behavior is still asserted via the case tables.
func TestMain(m *testing.M) {
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
code := m.Run()
slog.SetDefault(prev)
os.Exit(code)
}
func TestLoadMaxOrderCents(t *testing.T) { func TestLoadMaxOrderCents(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
env string // "" means unset env string // "" means unset
want int want int
}{ }{
{"empty falls back to default", "", 50000}, {"unset uses default", "", 20000},
{"explicit zero disables", "0", 0}, {"explicit zero disables", "0", 0},
{"valid override", "30000", 30000}, {"valid override", "50000", 50000},
{"negative falls back to default", "-1", 50000}, {"negative falls back to default", "-1", 20000},
{"garbage falls back to default", "two hundred", 50000}, {"garbage falls back to default", "two hundred", 20000},
{"empty after trim falls back to default", "", 50000}, {"empty after trim falls back to default", "", 20000},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -46,33 +32,3 @@ func TestLoadMaxOrderCents(t *testing.T) {
}) })
} }
} }
// TestLoadAbandoned3DSThresholdMinutes covers the env-parsing surface for
// the sweep threshold. Zero and negative fall back to the default (unlike
// MaxOrderCents where zero is a deliberate off-switch) because a zero
// threshold would sweep every requires_action row on the first tick and
// race the customer's in-progress challenge.
func TestLoadAbandoned3DSThresholdMinutes(t *testing.T) {
cases := []struct {
name string
env string // "" means unset
want int
}{
{"empty falls back to default", "", 30},
{"valid override", "45", 45},
{"zero falls back to default", "0", 30},
{"negative falls back to default", "-5", 30},
{"garbage falls back to default", "half an hour", 30},
{"valid minimum (warns)", "1", 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ABANDONED_3DS_THRESHOLD_MINUTES", tc.env)
got := loadAbandoned3DSThresholdMinutes()
if got != tc.want {
t.Errorf("loadAbandoned3DSThresholdMinutes() = %d, want %d", got, tc.want)
}
})
}
}

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

@@ -26,7 +26,6 @@ func Seed(db *gorm.DB) error {
{ {
Name: "Espresso", Name: "Espresso",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2, Ounces: 2,
BeanType: "Arabica", BeanType: "Arabica",
Price: 1800, // $18.00 Price: 1800, // $18.00
@@ -36,7 +35,6 @@ func Seed(db *gorm.DB) error {
{ {
Name: "Americano", Name: "Americano",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8, Ounces: 8,
BeanType: "Arabica", BeanType: "Arabica",
Price: 2200, // $22.00 Price: 2200, // $22.00
@@ -46,7 +44,6 @@ func Seed(db *gorm.DB) error {
{ {
Name: "Cappuccino", Name: "Cappuccino",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 6, Ounces: 6,
BeanType: "Arabica", BeanType: "Arabica",
Price: 2200, // $22.00 Price: 2200, // $22.00
@@ -56,7 +53,6 @@ func Seed(db *gorm.DB) error {
{ {
Name: "Latte", Name: "Latte",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12, Ounces: 12,
BeanType: "Arabica", BeanType: "Arabica",
Price: 2500, // $25.00 Price: 2500, // $25.00
@@ -66,7 +62,6 @@ func Seed(db *gorm.DB) error {
{ {
Name: "Mocha", Name: "Mocha",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 12, Ounces: 12,
BeanType: "Arabica", BeanType: "Arabica",
Price: 3000, // $30.00 Price: 3000, // $30.00
@@ -76,7 +71,6 @@ func Seed(db *gorm.DB) error {
{ {
Name: "Macchiato", Name: "Macchiato",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3, Ounces: 3,
BeanType: "Robusta Blend", BeanType: "Robusta Blend",
Price: 1800, // $18.00 Price: 1800, // $18.00

View File

@@ -11,7 +11,6 @@ type Coffee struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"` Name string `gorm:"size:255;not null" json:"name"`
RoastType string `gorm:"size:100" json:"roast_type"` RoastType string `gorm:"size:100" json:"roast_type"`
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
Ounces int `json:"ounces"` Ounces int `json:"ounces"`
BeanType string `gorm:"size:100" json:"bean_type"` BeanType string `gorm:"size:100" json:"bean_type"`
Price int `gorm:"not null" json:"price"` Price int `gorm:"not null" json:"price"`

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

@@ -10,14 +10,13 @@ import (
type OrderStatus string type OrderStatus string
const ( const (
OrderStatusPending OrderStatus = "pending" OrderStatusPending OrderStatus = "pending"
OrderStatusRequiresAction OrderStatus = "requires_action" OrderStatusPaid OrderStatus = "paid"
OrderStatusPaid OrderStatus = "paid" OrderStatusShipped OrderStatus = "shipped"
OrderStatusShipped OrderStatus = "shipped" OrderStatusDelivered OrderStatus = "delivered"
OrderStatusDelivered OrderStatus = "delivered" OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusCancelled OrderStatus = "cancelled" OrderStatusRefunded OrderStatus = "refunded"
OrderStatusRefunded OrderStatus = "refunded" OrderStatusFailed OrderStatus = "failed"
OrderStatusFailed OrderStatus = "failed"
) )
type TrackingStatus string type TrackingStatus string

View File

@@ -35,8 +35,6 @@ func (order *Order) DisplayState() string {
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return "Awaiting payment" return "Awaiting payment"
case OrderStatusRequiresAction:
return "Awaiting authentication"
case OrderStatusPaid: case OrderStatusPaid:
if order.TrackingNumber == "" { if order.TrackingNumber == "" {
return "Paid * awaiting label" return "Paid * awaiting label"
@@ -79,8 +77,6 @@ func (order *Order) DisplayKind() DisplayKind {
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return DisplayKindNeutral return DisplayKindNeutral
case OrderStatusRequiresAction:
return DisplayKindNeutral
case OrderStatusPaid: case OrderStatusPaid:
return DisplayKindAccent return DisplayKindAccent
case OrderStatusShipped: case OrderStatusShipped:

View File

@@ -14,7 +14,6 @@ func TestOrderDisplayState(t *testing.T) {
kind DisplayKind kind DisplayKind
}{ }{
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral}, {"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
{"requires_action", Order{Status: OrderStatusRequiresAction}, "Awaiting authentication", DisplayKindNeutral},
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent}, {"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent}, {"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent}, {"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},

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,8 +1,6 @@
package tui package tui
import ( import (
"strings"
"charm.land/bubbles/v2/viewport" "charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
@@ -10,54 +8,36 @@ import (
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
// accountBodyHeight returns the inner content height of the account body // updateAccountViewport initializes or resizes the detail viewport for the account page.
// panels. The body is quantized to whole order cards so the menu panel's // Follows the reference pattern: each page owns its own viewport(s).
// bottom border lines up exactly with the last visible card; the two gap func (m Model) updateAccountViewport() Model {
// rows around the body host the up/down scroll arrows. // Match the reference: only subtract header + footer, no extra margins
func (m Model) accountBodyHeight() int { // (account is a viewport page — views.go renders it without margin padding)
headerHeight := lipgloss.Height(m.BuildHeader()) headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter()) footerHeight := lipgloss.Height(m.BuildFooter())
budget := m.heightContainer - headerHeight - footerHeight - 2 verticalMarginHeight := headerHeight + footerHeight
cards := budget / orderCardHeight
if cards < 1 {
cards = 1
}
return cards*orderCardHeight - 2
}
// accountDetailWidth returns the width of the unboxed detail area: the chrome availableHeight := m.heightContainer - verticalMarginHeight
// width minus the menu panel and the gap. The detail side has no outer panel
// (its content draws its own boxes), so it reclaims the border + padding.
func (m Model) accountDetailWidth() int {
contentW, menuW, _, _ := m.shopLayout()
if m.size < large {
return contentW
}
return contentW - (menuW + 4) - 1
}
// updateAccountViewport initializes or resizes the detail viewport for the account page. leftWidth := 18
// The viewport spans the unboxed detail area to the right of the menu panel. rightWidth := m.widthContent - leftWidth - 2
func (m Model) updateAccountViewport() Model { detailWidth := rightWidth
detailW := m.accountDetailWidth()
// +2: no panel on the detail side, so the viewport spans the same rows
// as the menu panel including its border rows — tops and bottoms align.
availableHeight := m.accountBodyHeight() + 2
if m.size < large { if m.size < large {
// Stacked: the menu panel sits above the detail area detailWidth = m.widthContent
menuPanelHeight := len(models.AccountMenuItems) + 2 // In stacked mode, reduce available height by the left panel
availableHeight -= menuPanelHeight leftPanelHeight := len(models.AccountMenuItems) + 1
if availableHeight < 3 { availableHeight -= leftPanelHeight
availableHeight = 3 }
} if availableHeight < 3 {
availableHeight = 3
} }
if !m.account.viewportReady { if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight)) m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true m.account.viewportReady = true
} else { } else {
m.account.detailViewport.SetWidth(detailW) m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetHeight(availableHeight) m.account.detailViewport.SetHeight(availableHeight)
} }
@@ -66,7 +46,12 @@ func (m Model) updateAccountViewport() Model {
// getAccountDetailContent generates the content string for the current detail panel. // getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string { func (m Model) getAccountDetailContent() string {
detailContentWidth := m.accountDetailWidth() leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailContentWidth := rightWidth - 2
if m.size < large {
detailContentWidth = m.widthContent - 2
}
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) { if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return "" return ""
@@ -83,6 +68,7 @@ func (m Model) getAccountDetailContent() string {
case "cards": case "cards":
return m.CardsView(detailContentWidth) return m.CardsView(detailContentWidth)
case "faq": case "faq":
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
questionStyle := m.theme.TextHighlight().Bold(true) questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth) contentStyle := m.theme.TextBody().Width(detailContentWidth)
faqContent := "" faqContent := ""
@@ -93,7 +79,7 @@ func (m Model) getAccountDetailContent() string {
faqContent += "\n\n" faqContent += "\n\n"
} }
} }
detailView := faqContent detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.account.faqFocused { if !m.account.faqFocused {
hintStyle := m.theme.TextDim() hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq") detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
@@ -117,25 +103,19 @@ func (m Model) scrollToAccountDetailItem() Model {
switch { switch {
case m.account.orderViewState == 1: case m.account.orderViewState == 1:
// Order list uses whole-card windowing instead of viewport scroll: // No title in list mode, cards joined with JoinVertical (no gaps)
// shift the window just enough to keep the cursor's card visible. // Card: border(2) + content(2) + PaddingLeft only = 4 lines
page := m.orderListPageSize()
if m.account.orderCursor < m.account.orderWindowStart {
m.account.orderWindowStart = m.account.orderCursor
}
if m.account.orderCursor >= m.account.orderWindowStart+page {
m.account.orderWindowStart = m.account.orderCursor - page + 1
}
m.account.detailViewport.GotoTop()
return m
case m.account.addressListFocused:
// Titles were removed from the detail views, lists start at the top
headerOffset = 0 headerOffset = 0
itemHeight = 4
itemCount = len(m.currentOrderList())
selectedIndex = m.account.orderCursor
case m.account.addressListFocused:
headerOffset = 3
itemHeight = 5 itemHeight = 5
itemCount = len(m.SavedAddresses) itemCount = len(m.SavedAddresses)
selectedIndex = m.account.addressCursor selectedIndex = m.account.addressCursor
case m.account.cardListFocused: case m.account.cardListFocused:
headerOffset = 0 headerOffset = 3
itemHeight = 4 itemHeight = 4
itemCount = len(m.SavedCards) itemCount = len(m.SavedCards)
selectedIndex = m.account.cardCursor selectedIndex = m.account.cardCursor
@@ -163,30 +143,23 @@ func (m Model) scrollToAccountDetailItem() Model {
return m return m
} }
func (m Model) BuildAccountView() string { func (m Model) BuildAccountView(availableHeight int) string {
_, menuW, _, _ := m.shopLayout() leftWidth := 18
// Left panel: menu items. The highlight is blue while the user navigates // Build the left panel (menu items)
// the menu, and falls back to the gray pill when focus has moved into the leftPanel := ""
// detail area (browsing orders, managing addresses/cards, scrolling faq).
focusedInside := m.account.orderViewState > 0 || m.account.addressListFocused ||
m.account.cardListFocused || m.account.faqFocused
pillBg := m.theme.Highlight()
if focusedInside {
pillBg = cPill
}
var menu []string
for i, item := range models.AccountMenuItems { for i, item := range models.AccountMenuItems {
if m.account.cursor == i { if m.account.cursor == i {
menu = append(menu, lipgloss.NewStyle().Width(menuW). style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
Background(pillBg).Foreground(cWhite).Bold(true). leftPanel += style.Render(item) + "\n"
Render(" "+item))
} else { } else {
menu = append(menu, lipgloss.NewStyle().Width(menuW). style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
Render(" "+pBody.Render(item))) leftPanel += style.Render(item) + "\n"
} }
} }
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
leftContainer := m.theme.Base().
Width(leftWidth)
// Generate detail content and set it on the viewport // Generate detail content and set it on the viewport
detailContent := m.getAccountDetailContent() detailContent := m.getAccountDetailContent()
@@ -195,43 +168,25 @@ func (m Model) BuildAccountView() string {
// Viewport handles scrolling/clipping — no manual line-slicing needed // Viewport handles scrolling/clipping — no manual line-slicing needed
detailRendered := m.account.detailViewport.View() detailRendered := m.account.detailViewport.View()
// Responsive layout: menu panel beside the unboxed detail area on large, // Responsive layout: side-by-side on large, stacked on small/medium
// stacked on small/medium. The detail side draws its own item boxes.
var body string
if m.size < large { if m.size < large {
body = lipgloss.JoinVertical( return lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
panel(menuW, 0, menuContent), leftContainer.Render(leftPanel),
detailRendered,
)
} else {
bodyH := m.accountBodyHeight()
body = lipgloss.JoinHorizontal(
lipgloss.Top,
panel(menuW, bodyH, menuContent),
" ",
detailRendered, detailRendered,
) )
} }
// Blank rows separate the body from the header and footer boxes. When the return lipgloss.JoinHorizontal(
// windowed order list has hidden items, the arrows occupy those gap rows, lipgloss.Top,
// centered over the card column, so the cards themselves always span leftContainer.Render(leftPanel),
// exactly the menu panel's rows — top and bottom borders aligned. " ",
topGap := "" detailRendered,
if above := m.orderHiddenAbove(); above > 0 && m.size >= large { )
topGap = strings.Repeat(" ", menuW+5) +
orderScrollIndicator("↑", above, m.accountDetailWidth())
}
bottomGap := ""
if below := m.orderHiddenBelow(); below > 0 && m.size >= large {
bottomGap = strings.Repeat(" ", menuW+5) +
orderScrollIndicator("↓", below, m.accountDetailWidth())
}
return topGap + "\n" + body + "\n" + bottomGap
} }
func (m Model) AboutView(width int) string { func (m Model) AboutView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width) contentStyle := m.theme.TextBody().Width(width)
// USE this if wanting to add a corp name later // USE this if wanting to add a corp name later
// accentStyle := m.theme.TextHighlight().Bold(true) // accentStyle := m.theme.TextHighlight().Bold(true)
@@ -253,7 +208,7 @@ func (m Model) AboutView(width int) string {
width, width,
)) + "\n" )) + "\n"
return aboutContent return titleStyle.Render("About") + "\n\n" + aboutContent
} }
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
@@ -380,7 +335,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
case 0: case 0:
m.account.orderViewState = 1 m.account.orderViewState = 1
m.account.orderCursor = 0 m.account.orderCursor = 0
m.account.orderWindowStart = 0
m.account.detailViewport.GotoTop() m.account.detailViewport.GotoTop()
m.footer = []footerCommand{ m.footer = []footerCommand{
{key: "j/k", value: "orders"}, {key: "j/k", value: "orders"},

View File

@@ -1,9 +1,10 @@
package tui package tui
func (m Model) AddressesView(width int) string { func (m Model) AddressesView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width) contentStyle := m.theme.TextBody().Width(width)
lines := "" lines := titleStyle.Render("Address") + "\n\n"
if len(m.SavedAddresses) == 0 { if len(m.SavedAddresses) == 0 {
lines += contentStyle.Render("No saved addresses") lines += contentStyle.Render("No saved addresses")
} else { } else {

View File

@@ -5,9 +5,10 @@ import (
) )
func (m Model) CardsView(width int) string { func (m Model) CardsView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width) contentStyle := m.theme.TextBody().Width(width)
lines := "" lines := titleSTyle.Render("Cards") + "\n\n"
if m.SavedCardsIsEmpty() { if m.SavedCardsIsEmpty() {
lines += contentStyle.Render("No saved cards") lines += contentStyle.Render("No saved cards")
} else { } else {

View File

@@ -10,11 +10,11 @@ import (
) )
func (m Model) OrdersView(width int) string { func (m Model) OrdersView(width int) string {
return m.orderListView(m.HistoryOrders(), width) return m.orderListView("Order History", m.HistoryOrders(), width)
} }
func (m Model) ActiveOrdersView(width int) string { func (m Model) ActiveOrdersView(width int) string {
return m.orderListView(m.ActiveOrders(), width) return m.orderListView("Active Orders", m.ActiveOrders(), width)
} }
func (m Model) ActiveOrders() []models.Order { func (m Model) ActiveOrders() []models.Order {
@@ -65,95 +65,41 @@ func (m Model) currentOrderList() []models.Order {
return nil return nil
} }
// orderCardHeight is the rendered height of one order card: func (m Model) orderListView(title string, orders []models.Order, width int) string {
// border (2) + content lines (2), no vertical padding. titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
const orderCardHeight = 4
// orderListPageSize returns how many whole order cards fit in the detail
// area. Both scroll arrows render outside the list, in the gap rows above
// and below the body, so the cards use the full viewport height.
func (m Model) orderListPageSize() int {
n := m.account.detailViewport.Height() / orderCardHeight
if n < 1 {
n = 1
}
return n
}
// orderHiddenAbove returns how many order cards are scrolled off above the
// windowed list, or 0 when the windowed order list isn't showing.
func (m Model) orderHiddenAbove() int {
if m.account.orderViewState != 1 || !m.OrdersLoaded {
return 0
}
orders := m.currentOrderList()
if len(orders) == 0 {
return 0
}
page := m.orderListPageSize()
start := m.account.orderWindowStart
if start > len(orders)-page {
start = len(orders) - page
}
if start < 0 {
start = 0
}
return start
}
// orderHiddenBelow returns how many order cards are scrolled off below the
// windowed list, or 0 when the windowed order list isn't showing.
func (m Model) orderHiddenBelow() int {
if m.account.orderViewState == 2 || !m.OrdersLoaded {
return 0
}
orders := m.currentOrderList()
if len(orders) == 0 {
return 0
}
below := len(orders) - (m.orderHiddenAbove() + m.orderListPageSize())
if below < 0 {
below = 0
}
return below
}
// orderScrollIndicator renders the dim "more items" arrow row, centered
// across the card width.
func orderScrollIndicator(arrow string, count, width int) string {
return pDim.Width(width).Align(lipgloss.Center).
Render(fmt.Sprintf("%s %d more", arrow, count))
}
func (m Model) orderListView(orders []models.Order, width int) string {
contentStyle := m.theme.TextBody().Width(width) contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded { if !m.OrdersLoaded {
return contentStyle.Render("Loading orders...") return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
} }
if len(orders) == 0 { if len(orders) == 0 {
return contentStyle.Render("No orders yet") return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
} }
if m.account.orderViewState == 2 { if m.account.orderViewState == 2 {
return m.buildOrderDetailView(orders[m.account.orderCursor], width) return m.buildOrderDetailView(orders[m.account.orderCursor], width)
} }
// Whole-card windowing: render only fully-visible cards, never clipping a boxWidth := width - 2
// card mid-border. Both scroll arrows render in BuildAccountView's gap
// rows, so the cards span exactly the menu panel's rows. // Build cards and join vertically — no manual "\n" gaps means fixed spacing
page := m.orderListPageSize() var cards []string
start := m.orderHiddenAbove() for i, order := range orders {
end := start + page isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
if end > len(orders) { cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
end = len(orders) }
cardList := lipgloss.JoinVertical(lipgloss.Left, cards...)
// In preview mode (viewState 0), show title + hint
if m.account.orderViewState == 0 {
hintStyle := m.theme.TextDim()
return titleStyle.Render(title) + "\n" +
cardList + "\n" +
hintStyle.Render("enter: browse orders")
} }
var rows []string // In list browsing mode (viewState 1), just show cards — no title
for i := start; i < end; i++ { // (the left panel menu already shows "order history" as selected)
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor return cardList
rows = append(rows, m.buildOrderCard(orders[i], width, isSelected))
}
return lipgloss.JoinVertical(lipgloss.Left, rows...)
} }
// buildOrderCard renders a single order as a bordered box with fixed height. // buildOrderCard renders a single order as a bordered box with fixed height.

View File

@@ -9,42 +9,36 @@ import (
) )
func (m Model) updateCartViewport() Model { func (m Model) updateCartViewport() Model {
contentW, _, _, _ := m.shopLayout()
headerH := lipgloss.Height(m.BuildHeader()) headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs()) breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter()) footerH := lipgloss.Height(m.BuildFooter())
// 2 = blank gap rows around the cart list availH := m.heightContainer - headerH - footerH - breadH
availH := m.heightContainer - headerH - footerH - breadH - 2
if availH < 1 { if availH < 1 {
availH = 1 availH = 1
} }
if !m.cart.viewportReady { if !m.cart.viewportReady {
m.cart.viewport = viewport.New(viewport.WithWidth(contentW), viewport.WithHeight(availH)) m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.cart.viewport.KeyMap = viewport.KeyMap{} m.cart.viewport.KeyMap = viewport.KeyMap{}
m.cart.viewportReady = true m.cart.viewportReady = true
} else { } else {
m.cart.viewport.SetWidth(contentW) m.cart.viewport.SetWidth(m.widthContent)
m.cart.viewport.SetHeight(availH) m.cart.viewport.SetHeight(availH)
} }
return m return m
} }
func (m Model) generateCartContent() string { func (m Model) generateCartContent() string {
contentW, _, _, _ := m.shopLayout()
if m.IsCartEmpty() { if m.IsCartEmpty() {
emptyStyle := m.theme.TextDim(). emptyStyle := m.theme.TextDim().
Align(lipgloss.Center). Align(lipgloss.Center).
Width(contentW). Width(m.widthContent).
Padding(2, 0) Padding(2, 0)
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop") render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
return render return render
} }
cartItems := "" cartItems := ""
// Cart cards span the full chrome width so they line up with the boxWidth := m.widthContent - 4
// header and footer boxes.
boxWidth := contentW
itemSlice := m.GetCartItemsSlice() itemSlice := m.GetCartItemsSlice()
boxPadding := 0 boxPadding := 0
@@ -100,7 +94,7 @@ func (m Model) generateCartContent() string {
boxContent = m.theme.TextAccent().Render(boxContent) boxContent = m.theme.TextAccent().Render(boxContent)
} }
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth) box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
cartItems += box cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box)
if idx < len(itemSlice)-1 { if idx < len(itemSlice)-1 {
cartItems += "\n" cartItems += "\n"
@@ -129,9 +123,12 @@ func (m Model) CartView() string {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight) m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
} }
} }
// Blank rows separate the list from the chrome; no Place wrapper so the return lipgloss.Place(
// cards stay left-aligned with the header and footer boxes. m.widthContainer,
return "\n" + m.cart.viewport.View() + "\n" lipgloss.Height(m.cart.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.cart.viewport.View(),
)
} }
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) { func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {

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

@@ -6,26 +6,20 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
// BuildFooter renders the bottom chrome bar with the current page keybinds.
// Layout ported from the design prototype at ~/programming/test/shop.go.
func (m Model) BuildFooter() string { func (m Model) BuildFooter() string {
_, _, _, wideW := m.shopLayout() keybindStyle := m.theme.TextDim().Bold(true)
descStyle := m.theme.TextDim()
item := func(k, v string) string {
return pBody.Render(k) + " " + pDim.Render(v)
}
var keys string
if m.size == small { if m.size == small {
keys = item("m", "menu") footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
} else { return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
parts := make([]string, 0, len(m.footer))
for _, cmd := range m.footer {
parts = append(parts, item(cmd.key, cmd.value))
}
keys = strings.Join(parts, pDim.Render(" · "))
} }
row := lipgloss.NewStyle().Width(wideW).Align(lipgloss.Center).Render(keys) parts := []string{}
return panel(wideW, 0, row) for _, cmd := range m.footer {
parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value))
}
footerText := strings.Join(parts, " ")
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
} }

View File

@@ -2,72 +2,57 @@ package tui
import ( import (
"fmt" "fmt"
"image/color"
"strings"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
) )
// cartBlendColor mixes the colors of all cart items, weighted by quantity,
// so the header price tints toward whatever dominates the cart.
func (m Model) cartBlendColor() color.Color {
var rSum, gSum, bSum, n int
for _, item := range m.Cart {
r, g, b, ok := parseHexColor(item.Coffee.Color)
if !ok {
continue
}
rSum += r * item.Quantity
gSum += g * item.Quantity
bSum += b * item.Quantity
n += item.Quantity
}
if n == 0 {
return cWhite
}
return lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", rSum/n, gSum/n, bSum/n))
}
// BuildHeader renders the top chrome bar: brand, tab pills, and cart summary.
// Layout ported from the design prototype at ~/programming/test/shop.go.
func (m Model) BuildHeader() string { func (m Model) BuildHeader() string {
_, _, _, wideW := m.shopLayout() bold := m.theme.TextAccent().Bold(true).Render
accent := m.theme.TextAccent().Render
base := m.theme.Base().Render
cursor := m.theme.Base().Background(m.theme.Brand()).Render(" ")
brand := pWht.Bold(true).Render("terminal") mark := bold("t") + cursor
logo := bold("terminal")
menu := bold("m") + base(" ☰")
pill := lipgloss.NewStyle().Background(cPill).Foreground(cWhite).Bold(true).Padding(0, 1) tab := func(key, name string, active bool) string {
tab := func(key, label string, active bool) string {
if active { if active {
return pill.Render(label) return accent(key + " " + name)
} }
return pDim.Render(key+" ") + pBody.Render(label) return accent(key) + base(" "+name)
} }
shop := tab("s", "shop", m.currentPage == shopPage)
account := tab("a", "account", m.currentPage == accountPage)
var left string priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
if m.size == small { countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
left = brand cartLabel := base(" cart")
} else {
left = strings.Join([]string{
brand,
tab("s", "shop", m.currentPage == shopPage),
tab("a", "account", m.currentPage == accountPage),
}, " ")
}
cartLabel := pDim.Render("cart ")
if m.inCartFlow() { if m.inCartFlow() {
cartLabel = pWht.Bold(true).Render("cart ") cartLabel = accent(" cart")
} }
cart := cartLabel + cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
lipgloss.NewStyle().Foreground(m.cartBlendColor()).Bold(true).
Render(fmt.Sprintf("$%d", m.CalculateSubtotal()/100)) +
pDim.Render(fmt.Sprintf(" · %d", m.CartItemCount()))
gap := wideW - lipgloss.Width(left) - lipgloss.Width(cart) var tabs []string
if gap < 1 { switch m.size {
gap = 1 case small:
tabs = []string{mark, cart}
case medium:
tabs = []string{menu, logo, cart}
default:
tabs = []string{logo, shop, account, cart}
} }
row := left + strings.Repeat(" ", gap) + cart
return panel(wideW, 0, row) tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
}).Render()
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(tbl),
lipgloss.Center,
lipgloss.Center,
tbl,
)
} }

View File

@@ -75,6 +75,7 @@ type Model struct {
Fingerprint string Fingerprint string
AccessToken string AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh AuthFingerprintKey string // shared secret for /auth/token refresh
StripePublicKey string
// One-shop auth command - capture pubKeyStr in closure, not stored on Model // One-shop auth command - capture pubKeyStr in closure, not stored on Model
authCmd tea.Cmd authCmd tea.Cmd
@@ -142,7 +143,10 @@ type Model struct {
} }
type shopState struct { type shopState struct {
selected int selected int
menuViewport viewport.Model
detailViewport viewport.Model
viewportsReady bool
} }
type splashState struct { type splashState struct {
@@ -182,7 +186,6 @@ type accountState struct {
cursor int cursor int
orderViewState int orderViewState int
orderCursor int orderCursor int
orderWindowStart int // first fully-visible card in the windowed order list
orderYOffset int // saved scroll pos when entering order detail orderYOffset int // saved scroll pos when entering order detail
faqFocused bool faqFocused bool
addressListFocused bool addressListFocused bool
@@ -238,6 +241,7 @@ func (m Model) SwitchPage(p page) Model {
func (m Model) ShopSwitch() (Model, tea.Cmd) { func (m Model) ShopSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shopPage) m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
m.footer = []footerCommand{ m.footer = []footerCommand{
{key: "j/k", value: "products"}, {key: "j/k", value: "products"},
{key: "+/-", value: "qty"}, {key: "+/-", value: "qty"},
@@ -262,12 +266,13 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
{key: "q", value: "quit"}, {key: "q", value: "quit"},
} }
} else { } else {
// Keep this list short enough to fit on one footer line.
m.footer = []footerCommand{ m.footer = []footerCommand{
{key: "j/k", value: "items"}, {key: "j/k", value: "items"},
{key: "+/-", value: "qty"}, {key: "+/-", value: "qty"},
{key: "enter", value: "checkout"}, {key: "p/enter", value: "checkout"},
{key: "s", value: "shop"}, {key: "s", value: "shop"},
{key: "a", value: "account"},
{key: "pgup/pgdn", value: "scroll"},
{key: "?", value: "help"}, {key: "?", value: "help"},
{key: "q", value: "quit"}, {key: "q", value: "quit"},
} }
@@ -731,6 +736,7 @@ func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
if m.APIClient == nil { if m.APIClient == nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")} return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
} }
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{ tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{ Card: &stripe.CardParams{
Name: stripe.String(form.CardName), Name: stripe.String(form.CardName),
@@ -837,7 +843,6 @@ func NewModel(username string) Model {
{ {
Name: "Espresso", Name: "Espresso",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2, Ounces: 2,
BeanType: "Arabica", BeanType: "Arabica",
Price: 350, // $3.50 Price: 350, // $3.50
@@ -847,7 +852,6 @@ func NewModel(username string) Model {
{ {
Name: "Latte", Name: "Latte",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12, Ounces: 12,
BeanType: "Arabica Blend", BeanType: "Arabica Blend",
Price: 500, // $5.00 Price: 500, // $5.00
@@ -857,7 +861,6 @@ func NewModel(username string) Model {
{ {
Name: "Cappuccino", Name: "Cappuccino",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8, Ounces: 8,
BeanType: "Italian Blend", BeanType: "Italian Blend",
Price: 450, // $4.50 Price: 450, // $4.50
@@ -867,7 +870,6 @@ func NewModel(username string) Model {
{ {
Name: "Americano", Name: "Americano",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 16, Ounces: 16,
BeanType: "Arabica", BeanType: "Arabica",
Price: 400, // $4.00 Price: 400, // $4.00
@@ -877,7 +879,6 @@ func NewModel(username string) Model {
{ {
Name: "Mocha", Name: "Mocha",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 16, Ounces: 16,
BeanType: "Colombian", BeanType: "Colombian",
Price: 550, // $5.50 Price: 550, // $5.50
@@ -887,7 +888,6 @@ func NewModel(username string) Model {
{ {
Name: "Macchiato", Name: "Macchiato",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3, Ounces: 3,
BeanType: "Robusta Blend", BeanType: "Robusta Blend",
Price: 425, // $4.25 Price: 425, // $4.25
@@ -905,12 +905,11 @@ func NewModel(username string) Model {
} }
// NewModelWithAuth creates a new model with user authentication context. // NewModelWithAuth creates a new model with user authentication context.
// The publishable Stripe key is wired into stripe.Key once at startup in func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
// the SSH binary's main, so it is not threaded through here.
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string) Model {
m := NewModel("") m := NewModel("")
m.Fingerprint = fingerprint m.Fingerprint = fingerprint
m.AuthFingerprintKey = clientSecret m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey
m.currentPage = splashPage m.currentPage = splashPage
if apiURL != "" { if apiURL != "" {
m.APIClient = api.NewClient(apiURL, "") m.APIClient = api.NewClient(apiURL, "")
@@ -932,6 +931,10 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
return m return m
} }
func newViewport(w, h int) viewport.Model {
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
}
func (m Model) collectCardCmd() tea.Cmd { func (m Model) collectCardCmd() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if m.APIClient == nil { if m.APIClient == nil {

View File

@@ -1,70 +0,0 @@
package tui
import (
"image/color"
"strconv"
"strings"
"charm.land/lipgloss/v2"
)
// Palette and base styles for the boxed shop chrome.
// Ported from the design prototype at ~/programming/test/shop.go.
var (
cWhite = lipgloss.Color("#EAEAEA")
cBody = lipgloss.Color("#9A9A9A")
cDim = lipgloss.Color("#5A5A5A")
cLine = lipgloss.Color("#3A3A3A")
cInk = lipgloss.Color("#161616") // text on a light colored block
cGreen = lipgloss.Color("#4FB477")
cRed = lipgloss.Color("#E06C6C")
cGold = lipgloss.Color("#D0962E") // single accent
cPill = lipgloss.Color("#2E2E2E") // active-tab pill bg
pBody = lipgloss.NewStyle().Foreground(cBody)
pDim = lipgloss.NewStyle().Foreground(cDim)
pWht = lipgloss.NewStyle().Foreground(cWhite)
)
// parseHexColor splits a "#RRGGBB" string into its channel values.
func parseHexColor(s string) (r, g, b int, ok bool) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return int(v >> 16), int((v >> 8) & 0xFF), int(v & 0xFF), true
}
// contrastColor returns ink for light backgrounds and white for dark ones,
// using perceived luminance so text stays readable on any product color.
func contrastColor(hex string) color.Color {
r, g, b, ok := parseHexColor(hex)
if !ok {
return cWhite
}
lum := (299*r + 587*g + 114*b) / 1000
if lum > 140 {
return cInk
}
return cWhite
}
// panel wraps content in a rounded box. textW is the inner text width and
// innerH the inner content height; the total render size is textW+4 by
// innerH+2. Ported from the design prototype at ~/programming/test/shop.go,
// adjusted for lipgloss v2 where Width/Height include the border.
func panel(textW, innerH int, content string) string {
s := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cLine).
Padding(0, 1).
Width(textW + 4)
if innerH > 0 {
s = s.Height(innerH + 2)
}
return s.Render(content)
}

View File

@@ -276,7 +276,9 @@ func (m Model) updatePaymentViewport() Model {
breadH := lipgloss.Height(m.BuildBreadcrumbs()) breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter()) footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH availH := m.heightContainer - headerH - footerH - breadH
availH = max(availH, 1) if availH < 1 {
availH = 1
}
if !m.payment.viewportReady { if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.payment.viewport.KeyMap = viewport.KeyMap{} m.payment.viewport.KeyMap = viewport.KeyMap{}
@@ -301,7 +303,7 @@ func (m Model) PaymentPageView() string {
m.widthContainer, m.widthContainer,
m.payment.viewport.Height(), m.payment.viewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.renderPaymentHTTPSView(), m.renderPaymentHttpsView(),
) )
} else if m.payment.view == 0 && m.payment.form == nil { } else if m.payment.view == 0 && m.payment.form == nil {
content = m.RenderCardList() content = m.RenderCardList()
@@ -353,16 +355,11 @@ func (m Model) RenderCardList() string {
boxes = append(boxes, box) boxes = append(boxes, box)
} }
// SSH inline card-add is dormant until i have a norwegian org sshFocused := m.payment.cardCursor == len(m.SavedCards)
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
// 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) boxes = append(boxes, sshBox)
httpsFocused := m.payment.cardCursor == len(m.SavedCards) httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused) httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox) boxes = append(boxes, httpsBox)
@@ -372,7 +369,7 @@ func (m Model) RenderCardList() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...) return lipgloss.JoinVertical(lipgloss.Left, parts...)
} }
func (m Model) renderPaymentHTTPSView() string { func (m Model) renderPaymentHttpsView() string {
baseStyle := m.theme.TextLabel() baseStyle := m.theme.TextLabel()
accentStyle := m.theme.TextHighlight() accentStyle := m.theme.TextHighlight()
@@ -504,7 +501,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.cardCursor-- m.payment.cardCursor--
} }
case "down", "j": case "down", "j":
if m.payment.cardCursor < len(m.SavedCards) { if m.payment.cardCursor < len(m.SavedCards)+1 {
m.payment.cardCursor++ m.payment.cardCursor++
} }
@@ -514,8 +511,17 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SelectedCard = &selected m.SelectedCard = &selected
return m.ReviewSwitch() return m.ReviewSwitch()
} }
// Browser flow, the only reachable add-card path while the SSH if m.payment.cardCursor == len(m.SavedCards) {
// inline form is dormant (see RenderCardList) 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
m.payment.view = 2 m.payment.view = 2
m.payment.collectURL = nil m.payment.collectURL = nil
m.payment.collectTimeOut = false m.payment.collectTimeOut = false
@@ -540,6 +546,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 { if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.payment.cardCursor-- 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) return m, m.deleteCardCmd(card.ID)
} }
} }

View File

@@ -1,91 +0,0 @@
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")
}
}

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

@@ -1,194 +0,0 @@
package tui
import (
"regexp"
"strings"
"testing"
"terminalShop/pkg/models"
)
var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m")
// TestRenderShopSmoke prints the rendered shop frame so chrome alignment can
// be inspected, and asserts that the header, body, and footer boxes share the
// same left and right edges.
func TestRenderShopSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 4}
m = m.updateLayout(100, 40)
m, _ = m.ShopSwitch()
out := m.renderString()
plain := ansiRE.ReplaceAllString(out, "")
t.Log("\n" + plain)
// Collect left/right border columns of every box-drawing row.
var lefts, rights []int
for _, line := range strings.Split(plain, "\n") {
runes := []rune(line)
first, last := -1, -1
for i, r := range runes {
if strings.ContainsRune("╭╮╰╯│", r) {
if first == -1 {
first = i
}
last = i
}
}
if first != -1 {
lefts = append(lefts, first)
rights = append(rights, last)
}
}
if len(lefts) == 0 {
t.Fatal("no box-drawing rows found")
}
for i := range lefts {
if lefts[i] != lefts[0] {
t.Errorf("left edge misaligned: row %d at col %d, expected %d", i, lefts[i], lefts[0])
break
}
if rights[i] != rights[0] {
t.Errorf("right edge misaligned: row %d at col %d, expected %d", i, rights[i], rights[0])
break
}
}
}
// TestRenderAccountSmoke verifies the account page boxes stay within the
// header/footer chrome bounds. The detail area is unboxed, so inner boxes may
// be narrower than the chrome but must never poke outside it.
func TestRenderAccountSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
plain := ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
var lefts, rights []int
for _, line := range strings.Split(plain, "\n") {
runes := []rune(line)
first, last := -1, -1
for i, r := range runes {
if strings.ContainsRune("╭╮╰╯│", r) {
if first == -1 {
first = i
}
last = i
}
}
if first != -1 {
lefts = append(lefts, first)
rights = append(rights, last)
}
}
if len(lefts) == 0 {
t.Fatal("no box-drawing rows found")
}
// Row 0 is the header box: it defines the chrome bounds.
for i := range lefts {
if lefts[i] < lefts[0] || rights[i] > rights[0] {
t.Errorf("box outside chrome: row %d spans %d-%d, chrome is %d-%d",
i, lefts[i], rights[i], lefts[0], rights[0])
break
}
}
}
// TestRenderOrderWindowing verifies the windowed order list: whole cards
// only, with ↑/↓ arrow rows marking hidden items.
func TestRenderOrderWindowing(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m.OrdersLoaded = true
for i := 1; i <= 9; i++ {
m.Orders = append(m.Orders, models.Order{
ID: uint(9000 + i),
Status: models.OrderStatusPaid,
Total: 1000 + i*100,
})
}
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
// Preview mode: window starts at the top, arrow only below.
plain := ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
if !strings.Contains(plain, "↓ 4 more") {
t.Errorf("preview mode: expected '↓ 4 more' indicator")
}
if strings.Contains(plain, "↑") {
t.Errorf("preview mode: unexpected up arrow")
}
// Browse mode scrolled to the middle: arrows on both ends.
m.account.orderViewState = 1
m.account.orderCursor = 6
m = m.scrollToAccountDetailItem()
plain = ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
if !strings.Contains(plain, "↑ 2 more") || !strings.Contains(plain, "↓ 2 more") {
t.Errorf("browse mode: expected '↑ 2 more' and '↓ 2 more' indicators")
}
}
// TestRenderCartSmoke verifies the cart page cards line up with the chrome.
func TestRenderCartSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
// Seed coffees carry no IDs, so assign them for distinct cart keys
for i := range m.Coffees {
m.Coffees[i].ID = uint(i + 1)
}
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 2}
m.Cart[m.Coffees[1].ID] = &models.CartItem{CoffeeID: m.Coffees[1].ID, Coffee: m.Coffees[1], Quantity: 1}
m = m.updateLayout(100, 40)
m, _ = m.CartSwitch()
plain := ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
var lefts, rights []int
for _, line := range strings.Split(plain, "\n") {
runes := []rune(line)
first, last := -1, -1
for i, r := range runes {
if strings.ContainsRune("╭╮╰╯│", r) {
if first == -1 {
first = i
}
last = i
}
}
if first != -1 {
lefts = append(lefts, first)
rights = append(rights, last)
}
}
if len(lefts) == 0 {
t.Fatal("no box-drawing rows found")
}
// Every box row (header, cards, footer) must share the chrome edges.
for i := range lefts {
if lefts[i] != lefts[0] || rights[i] != rights[0] {
t.Errorf("box edge misaligned: row %d spans %d-%d, expected %d-%d",
i, lefts[i], rights[i], lefts[0], rights[0])
break
}
}
}
// TestRenderShopSmokeStacked prints the stacked (medium/small) layouts.
func TestRenderShopSmokeStacked(t *testing.T) {
for _, size := range [][2]int{{60, 32}, {45, 30}} {
m := NewModel("smoke")
m.Loading = false
m = m.updateLayout(size[0], size[1])
m, _ = m.ShopSwitch()
t.Logf("--- %dx%d ---\n%s", size[0], size[1], ansiRE.ReplaceAllString(m.renderString(), ""))
}
}

View File

@@ -4,173 +4,163 @@ import (
"fmt" "fmt"
"strings" "strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
const ( func (m Model) calculateShopWidths() (int, int) {
// shopBodyInnerHeight fixes the inner height of the shop body panels so menuWidth := 14
// the detail box never resizes with the description. if len(m.Coffees) == 0 {
shopBodyInnerHeight = 13 if m.size < large {
// shopDescLines pins the description region to an exact line count. return m.widthContent, m.widthContent
shopDescLines = 3 }
) return menuWidth, m.widthContent - menuWidth
}
// shopLayout returns the shared chrome widths, all derived from one content for _, c := range m.Coffees {
// width so the header, body panels, and footer line up exactly. if w := lipgloss.Width(c.Name); w > menuWidth {
// Math ported from the design prototype at ~/programming/test/shop.go: menuWidth = w
// menu(menuW+4) + gap(1) + detail(detailW+4) = contentW = wideW + 4. }
func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) { }
contentW = m.widthContent if menuWidth > 0 {
contentW = min(contentW, 74) menuWidth += 4
wideW = contentW - 4 }
if m.size < large { if m.size < large {
// Stacked layout: both panels span the full content width. menuWidth = m.widthContent
return contentW, wideW, wideW, wideW
} }
menuW = 16 detailWidth := m.widthContent - menuWidth
detailW = contentW - 25 if m.size < large {
return contentW, menuW, detailW, wideW detailWidth = m.widthContent
}
return menuWidth, detailWidth
} }
// roastLevel maps a roast type to a 1-5 intensity for the roast meter. func (m Model) updateShopViewports() Model {
// It is the fallback for products stored before the roast_level column existed. headerHeight := lipgloss.Height(m.BuildHeader())
func roastLevel(roastType string) int { footerHeight := lipgloss.Height(m.BuildFooter())
rt := strings.ToLower(roastType) availableHeight := m.heightContainer - headerHeight - footerHeight
switch {
case strings.Contains(rt, "dark"): if len(m.Coffees) == 0 {
return 5 return m
case strings.Contains(rt, "light"):
return 2
default:
return 3
} }
menuWidth, detailWidth := m.calculateShopWidths()
if !m.shop.viewportsReady {
m.shop.menuViewport = newViewport(menuWidth, availableHeight)
m.shop.menuViewport.KeyMap = viewport.KeyMap{} // menu nav is manual
m.shop.detailViewport = newViewport(detailWidth, availableHeight)
m.shop.detailViewport.KeyMap = modifiedKeyMap
m.shop.viewportsReady = true
} else {
m.shop.menuViewport.SetWidth(menuWidth)
m.shop.menuViewport.SetHeight(availableHeight)
m.shop.detailViewport.SetWidth(detailWidth)
m.shop.detailViewport.SetHeight(availableHeight)
}
return m
} }
// clampLines pads or truncates rendered text to exactly n lines so regions func (m Model) getShopMenuContent() string {
// like the description never change the panel height. menuWidth, _ := m.calculateShopWidths()
func clampLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[:n]
}
for len(lines) < n {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
// roastMeter renders the strength dots, e.g. ● ● ● ○ ○ selectedColor := m.theme.Highlight()
func roastMeter(n int) string { if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
n = max(0, min(n, 5)) if c := m.Coffees[m.shop.selected].Color; c != "" {
on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n)) selectedColor = lipgloss.Color(c)
off := pDim.Render(strings.Repeat("○ ", 5-n)) }
return strings.TrimRight(on+off, " ") }
}
var normal, highlighted lipgloss.Style
if m.size < large {
menuWidth = m.widthContent
normal = m.theme.TextBody().
Width(menuWidth - 1).Align(lipgloss.Center)
highlighted = m.theme.TextAccent().
Width(menuWidth - 1).Align(lipgloss.Center).
Background(selectedColor).Bold(true)
} else {
normal = m.theme.TextBody().
Width(menuWidth+2).Padding(0, 1)
highlighted = m.theme.TextAccent().
Width(menuWidth+2).Padding(0, 1).
Background(selectedColor).Bold(true)
}
func (m Model) shopMenuContent(w int) string {
var b strings.Builder var b strings.Builder
for i, c := range m.Coffees { for i, c := range m.Coffees {
if i == m.shop.selected { if i == m.shop.selected {
b.WriteString(lipgloss.NewStyle().Width(w). b.WriteString(highlighted.Render(c.Name) + "\n")
Background(lipgloss.Color(c.Color)).
Foreground(contrastColor(c.Color)).
Bold(true).
Render(" · " + c.Name))
} else { } else {
dot := lipgloss.NewStyle().Foreground(lipgloss.Color(c.Color)).Render("·") b.WriteString(normal.Render(c.Name) + "\n")
b.WriteString(lipgloss.NewStyle().Width(w).
Render(" " + dot + " " + pBody.Render(c.Name)))
}
if i < len(m.Coffees)-1 {
b.WriteString("\n")
} }
} }
return b.String() return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
} }
func (m Model) shopDetailContent(w int) string { func (m Model) getShopDetailContent() string {
if len(m.Coffees) == 0 {
return ""
}
coffee := m.Coffees[m.shop.selected] coffee := m.Coffees[m.shop.selected]
_, detailWidth := m.calculateShopWidths()
if m.size >= large {
detailWidth -= 2
}
quantity := 0 quantity := 0
if item, exists := m.Cart[coffee.ID]; exists { if item, exists := m.Cart[coffee.ID]; exists {
quantity = item.Quantity quantity = item.Quantity
} }
name := pWht.Bold(true).Render(coffee.Name) detail := lipgloss.JoinVertical(lipgloss.Left,
m.theme.TextAccent().Bold(true).Render(coffee.Name),
sep := pDim.Render(" · ") m.theme.TextBody().Render(
spec := pBody.Render(strings.ToUpper(coffee.RoastType)) + sep + fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
pDim.Render(fmt.Sprintf("%dOZ", coffee.Ounces)) + sep + ),
pDim.Render(strings.ToUpper(coffee.BeanType))
level := coffee.RoastLevel
if level < 1 || level > 5 {
level = roastLevel(coffee.RoastType)
}
strength := pDim.Render("roast ") + roastMeter(level)
price := lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).
Render(fmt.Sprintf("$%d.%02d", coffee.Price/100, coffee.Price%100)) +
pDim.Render(" usd")
// Stock is not modeled in the backend yet, so the indicator is static.
stock := lipgloss.NewStyle().Foreground(cGreen).Render("● in stock")
desc := clampLines(pBody.Width(w).Render(coffee.Description), shopDescLines)
// stepper: git-diff coloring — red minus, green plus
minus := lipgloss.NewStyle().Foreground(cRed).Bold(true).Render("−")
plus := lipgloss.NewStyle().Foreground(cGreen).Bold(true).Render("+")
count := lipgloss.NewStyle().Foreground(cWhite).Bold(true).
Padding(0, 2).Render(fmt.Sprintf("%d", quantity))
stepper := pDim.Render("qty ") + minus + count + plus
return lipgloss.JoinVertical(lipgloss.Left,
name,
spec,
strength,
"", "",
price+" "+stock, lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
),
"", "",
desc, m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
"", "",
stepper, fmt.Sprintf("- %d +", quantity),
) )
return lipgloss.NewStyle().Width(detailWidth).Render(detail)
} }
func (m Model) ShopView() string { func (m Model) ShopView() string {
if !m.shop.viewportsReady {
m = m.updateShopViewports()
}
if len(m.Coffees) == 0 { if len(m.Coffees) == 0 {
return lipgloss.Place(m.widthContent, m.heightContainer, return lipgloss.Place(m.widthContent, m.heightContainer,
lipgloss.Center, lipgloss.Center, "No products available.") lipgloss.Center, lipgloss.Center, "No products available.")
} }
_, menuW, detailW, _ := m.shopLayout() m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
mc := m.shopMenuContent(menuW)
dc := m.shopDetailContent(detailW)
var body string
if m.size < large { if m.size < large {
body = lipgloss.JoinVertical(lipgloss.Left, return lipgloss.JoinVertical(lipgloss.Top,
panel(menuW, 0, mc), m.shop.menuViewport.View(),
panel(detailW, 0, dc), m.shop.detailViewport.View(),
)
} else {
bodyH := max(shopBodyInnerHeight, lipgloss.Height(mc), lipgloss.Height(dc))
body = lipgloss.JoinHorizontal(lipgloss.Top,
panel(menuW, bodyH, mc),
" ",
panel(detailW, bodyH, dc),
) )
} }
return lipgloss.JoinHorizontal(lipgloss.Top,
// Blank rows separate the body from the header and footer boxes. m.shop.menuViewport.View(),
return "\n" + body + "\n" " ",
m.shop.detailViewport.View(),
)
} }
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) { func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
if len(m.Coffees) == 0 { if len(m.Coffees) == 0 {
return m, nil return m, nil
} }
@@ -207,7 +197,16 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
} }
return m, nil // Pass messages to the detail viewport so pgup/pgdn scroll works
m.shop.detailViewport, cmd = m.shop.detailViewport.Update(msg)
cmds = append(cmds, cmd)
if m.shop.viewportsReady {
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
}
return m, tea.Batch(cmds...)
} }
func (m Model) shopMoveSelected(previous bool) Model { func (m Model) shopMoveSelected(previous bool) Model {
@@ -217,8 +216,18 @@ func (m Model) shopMoveSelected(previous bool) Model {
} else { } else {
next++ next++
} }
next = max(next, 0) if next < 0 {
next = min(next, len(m.Coffees)-1) next = 0
}
if next >= len(m.Coffees) {
next = len(m.Coffees) - 1
}
m.shop.selected = next m.shop.selected = next
if m.shop.viewportsReady {
targetY := (m.shop.selected + 2) * 1
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
m.shop.detailViewport.GotoTop()
}
return m return m
} }

View File

@@ -8,13 +8,13 @@ import (
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string { func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
padded := lipgloss.PlaceHorizontal(totalWidth, position, content) padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
base := m.theme.Base().Border(lipgloss.RoundedBorder()).Width(totalWidth) base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
var style lipgloss.Style var style lipgloss.Style
if selected { if selected {
style = base.BorderForeground(cWhite) style = base.BorderForeground(m.theme.Highlight())
} else { } else {
style = base.BorderForeground(cLine) style = base.BorderForeground(m.theme.Border())
} }
return style.Padding(paddingV, paddingH).Render(padded) return style.Padding(paddingV, paddingH).Render(padded)
} }

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

@@ -16,6 +16,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if tick.seq == m.resizeSeq { if tick.seq == m.resizeSeq {
m = m.updateLayout(m.pendingWidth, m.pendingHeight) m = m.updateLayout(m.pendingWidth, m.pendingHeight)
switch m.currentPage { switch m.currentPage {
case shopPage:
m = m.updateShopViewports()
case cartPage: case cartPage:
m = m.updateCartViewport() m = m.updateCartViewport()
case shippingPage: case shippingPage:

View File

@@ -62,8 +62,7 @@ func (m Model) renderString() string {
if breadcrumbs != "" { if breadcrumbs != "" {
breadcrumbsHeight = lipgloss.Height(breadcrumbs) breadcrumbsHeight = lipgloss.Height(breadcrumbs)
} }
footer := m.BuildFooter() footerHeight := 1
footerHeight := lipgloss.Height(footer)
marginTop := 1 marginTop := 1
marginBottom := 1 marginBottom := 1
bufferSpace := 1 bufferSpace := 1
@@ -88,15 +87,17 @@ func (m Model) renderString() string {
errorStyle = errorStyle.Width(m.widthContent - 4) errorStyle = errorStyle.Width(m.widthContent - 4)
} }
errorBanner := errorStyle.Render(m.error.message) errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent() content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
} else if m.notice != nil { } else if m.notice != nil {
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1) noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
noticeBanner := noticeStyle.Render(m.notice.message) noticeBanner := noticeStyle.Render(m.notice.message)
content = noticeBanner + "\n" + m.buildPageContent() content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
} else { } else {
content = m.buildPageContent() content = m.buildPageContent(availableContentHeight)
} }
footer := m.BuildFooter()
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage || isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage || m.currentPage == shippingPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage || m.currentPage == accountPage m.currentPage == confirmPage || m.currentPage == accountPage
@@ -136,10 +137,10 @@ func (m Model) renderString() string {
) )
} }
func (m Model) buildPageContent() string { func (m Model) buildPageContent(height int) string {
switch m.currentPage { switch m.currentPage {
case accountPage: case accountPage:
return m.BuildAccountView() return m.BuildAccountView(height)
case shippingPage: case shippingPage:
return m.ShippingPageView() return m.ShippingPageView()
case paymentPage: case paymentPage:

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