Compare commits
1 Commits
feat/roast
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b5ad61eca6 |
11
.github/workflows/deploy.yml
vendored
11
.github/workflows/deploy.yml
vendored
@@ -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"
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,20 +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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconcileUnshipped finds paid orders older than 24h with no tracking
|
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
|
||||||
// number and posts a single Slack reminder. Pure read + notify.
|
// number and posts single Slack reminder. Pure read + notif
|
||||||
func ReconcileUnshipped(ctx context.Context) {
|
func ReconcileUnshipped() {
|
||||||
db := database.GetDB().WithContext(ctx)
|
db := database.GetDB()
|
||||||
|
|
||||||
var orders []models.Order
|
var orders []models.Order
|
||||||
cutoff := time.Now().Add(-24 * time.Hour)
|
cutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
|||||||
@@ -1,520 +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
|
|
||||||
}
|
|
||||||
@@ -28,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.
|
||||||
@@ -184,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)
|
||||||
}
|
}
|
||||||
@@ -212,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)
|
||||||
|
|||||||
@@ -1,224 +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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
api/main.go
44
api/main.go
@@ -19,8 +19,6 @@ 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"
|
||||||
@@ -51,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)
|
||||||
@@ -79,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{
|
||||||
@@ -114,7 +106,7 @@ func main() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
handlers.ReconcileOrders(ctx)
|
handlers.ReconcileOrders(cfg.StripeSecretKey)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,18 +114,14 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
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,21 +137,7 @@ func main() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
handlers.ReconcileExpiredCards(ctx)
|
handlers.ReconcileExpiredCards(cfg.StripeSecretKey)
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(15 * time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
handlers.ReconcileStale3DSOrders(ctx, threshold)
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -178,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -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
14
main.go
@@ -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
|
||||||
|
|||||||
@@ -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()...)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
@@ -137,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
|
||||||
@@ -155,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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -355,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)
|
||||||
|
|
||||||
@@ -506,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++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,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
|
||||||
@@ -542,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(), ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
244
pkg/tui/shop.go
244
pkg/tui/shop.go
@@ -4,174 +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 {
|
||||||
if contentW > 74 {
|
menuWidth += 4
|
||||||
contentW = 74
|
|
||||||
}
|
}
|
||||||
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) {
|
||||||
on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n))
|
if c := m.Coffees[m.shop.selected].Color; c != "" {
|
||||||
off := pDim.Render(strings.Repeat("○ ", 5-n))
|
selectedColor = lipgloss.Color(c)
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -208,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 {
|
||||||
@@ -225,5 +223,11 @@ func (m Model) shopMoveSelected(previous bool) Model {
|
|||||||
next = len(m.Coffees) - 1
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user