Compare commits
19 Commits
feat/sca-t
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
| a33cbce667 | |||
| 5dd196834f | |||
| e85dc7fd4d | |||
| ff562c7427 | |||
| aa34043e9b | |||
| 5dd22fb8be | |||
| 52d533703b | |||
| c9f8b1e674 | |||
| d592249efb | |||
| cfc97eb1ff | |||
| c112797875 | |||
| 75225548ae | |||
| 53e934be3b | |||
| fc02f331d2 | |||
| bb960c31f8 | |||
| 8b753e4f4b | |||
| 90645958e5 | |||
| 135f1361e4 | |||
| 64deba7e03 |
11
.github/workflows/deploy.yml
vendored
11
.github/workflows/deploy.yml
vendored
@@ -22,6 +22,15 @@ jobs:
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
port: ${{ secrets.SERVER_PORT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd ~/terminalShop
|
||||
git pull
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
deployed=$(git rev-parse HEAD)
|
||||
expected="${{ github.sha }}"
|
||||
if [ "$deployed" != "$expected" ]; then
|
||||
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
|
||||
exit 1
|
||||
fi
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
echo "deploy verified: HEAD=$deployed"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
|
||||
docs/sca-psd2-compliance.md
|
||||
accept-a-payment/
|
||||
codemap/
|
||||
|
||||
|
||||
FULFILLMENT.md
|
||||
|
||||
@@ -71,3 +71,4 @@ uses SQLite by default so there's nothing to spin up.
|
||||
## inspiration
|
||||
|
||||
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
|
||||
# something
|
||||
|
||||
3
api/doc.go
Normal file
3
api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Command api is the HTTP backend: it boots the router, bolts on all the
|
||||
// middleware, and answers the requests the TUI throws at it.
|
||||
package main
|
||||
@@ -42,7 +42,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
var addresses []models.Address
|
||||
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"addresses": addresses,
|
||||
})
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "address successfully deleted",
|
||||
})
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Addresses []interface{} `json:"addresses"`
|
||||
Addresses []any `json:"addresses"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
|
||||
@@ -108,7 +108,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
middleware.RecordAuthAttempt("success")
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800, // 30 minutes in seconds
|
||||
@@ -146,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{
|
||||
"errors": errors,
|
||||
})
|
||||
return
|
||||
@@ -173,7 +173,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestGetCards_FiltersExpiredRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewCardHandler("", "")
|
||||
handler := NewCardHandler("")
|
||||
req := authRequest("GET", "/api/v1/cards", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetCards(w, req)
|
||||
@@ -91,7 +91,7 @@ func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCardHandler("", "")
|
||||
handler := NewCardHandler("")
|
||||
idStr := strconv.FormatUint(uint64(card.ID), 10)
|
||||
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
|
||||
@@ -67,13 +67,13 @@ func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// CardHandler handles saved payment method CRUD.
|
||||
type CardHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
appURL string
|
||||
}
|
||||
|
||||
// NewCardHandler creates a new card handler with the Stripe secret key.
|
||||
func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
|
||||
return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||
// NewCardHandler creates a new card handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewCardHandler(appURL string) *CardHandler {
|
||||
return &CardHandler{appURL: appURL}
|
||||
}
|
||||
|
||||
// GetCards returns all saved cards for the authenticated user.
|
||||
@@ -89,12 +89,12 @@ func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
|
||||
userID, time.Now(),
|
||||
).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cards": cards,
|
||||
})
|
||||
}
|
||||
|
||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
|
||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any)
|
||||
|
||||
// GetCard returns a single saved card by ID.
|
||||
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -114,7 +114,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -171,8 +171,6 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
@@ -197,7 +195,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||
return
|
||||
}
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||
})
|
||||
}
|
||||
@@ -234,8 +232,6 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
// Ensure the user has a Stripe customer, creating one if needed.
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
@@ -302,7 +298,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -335,7 +331,6 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Detach from Stripe (best-effort; don't block deletion on Stripe errors).
|
||||
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
|
||||
stripe.Key = h.stripeKey
|
||||
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
|
||||
}
|
||||
|
||||
@@ -347,7 +342,7 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.CardDeleted(userID, card.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "card deleted",
|
||||
})
|
||||
}
|
||||
@@ -355,8 +350,10 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
// expireStoredCard removes a card whose retention deadline elapsed.
|
||||
// Local state (cart references + card row) is committed atomically;
|
||||
// Stripe detach and audit run only after the local commit so retries
|
||||
// cannot double-fire the audit event.
|
||||
func expireStoredCard(db *gorm.DB, card *models.Card, stripeKey string) error {
|
||||
// cannot double-fire the audit event. Stripe detach is gated on
|
||||
// stripe.Key being non-empty so tests (which never init the SDK key)
|
||||
// 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 := 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)
|
||||
@@ -369,8 +366,7 @@ func expireStoredCard(db *gorm.DB, card *models.Card, stripeKey string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if stripeKey != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
|
||||
stripe.Key = stripeKey
|
||||
if stripe.Key != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
|
||||
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
|
||||
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -58,7 +59,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
|
||||
t.Fatalf("seed cart: %v", err)
|
||||
}
|
||||
|
||||
ReconcileExpiredCards("")
|
||||
ReconcileExpiredCards(context.Background())
|
||||
|
||||
var remaining []models.Card
|
||||
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
|
||||
@@ -117,7 +118,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
ReconcileExpiredCards("")
|
||||
ReconcileExpiredCards(context.Background())
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
)
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
maxOrderCents int
|
||||
}
|
||||
@@ -36,9 +35,10 @@ type CartHandler struct {
|
||||
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
|
||||
const maxAuthIssuances = 4
|
||||
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
|
||||
// NewCartHandler creates a new cart handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler {
|
||||
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
|
||||
}
|
||||
|
||||
// getOrCreateCart finds the users cart or creates one.
|
||||
@@ -55,18 +55,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var items []models.CartItem
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
|
||||
subtotal := 0
|
||||
itemsData := make([]map[string]interface{}, 0, len(items))
|
||||
itemsData := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
lineTotal := item.Coffee.Price * item.Quantity
|
||||
subtotal += lineTotal
|
||||
itemsData = append(itemsData, map[string]interface{}{
|
||||
itemsData = append(itemsData, map[string]any{
|
||||
"id": item.ID,
|
||||
"coffee_id": item.CoffeeID,
|
||||
"quantity": item.Quantity,
|
||||
@@ -75,7 +75,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{}
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"items": itemsData,
|
||||
"subtotal": subtotal,
|
||||
"address_id": cart.AddressID,
|
||||
@@ -94,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "address set",
|
||||
})
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "card set",
|
||||
})
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "cart cleared",
|
||||
})
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
middleware.RecordCartConversion("validation_card_expired")
|
||||
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
@@ -330,8 +330,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
// Ensure the user has a Stripe customer (shared helper with cards handler).
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
middleware.RecordCartConversion("stripe_customer_failed")
|
||||
@@ -384,9 +382,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if h.maxOrderCents > 0 && total > h.maxOrderCents {
|
||||
middleware.RecordCartConversion("validation_over_limit")
|
||||
audit.CartRejected(userID, total, h.maxOrderCents)
|
||||
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
|
||||
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"total_cents": total,
|
||||
"limit_cents": h.maxOrderCents,
|
||||
})
|
||||
@@ -507,7 +506,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(cart).Updates(map[string]interface{}{
|
||||
return tx.Model(cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -534,14 +533,15 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
go notify.SlackOrderPaid(&order)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order": order,
|
||||
})
|
||||
}
|
||||
|
||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
|
||||
// The order stays pending. the webhook flips it to paid once the customer
|
||||
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
|
||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order
|
||||
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md);
|
||||
// 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.
|
||||
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
|
||||
db := database.GetDB()
|
||||
|
||||
@@ -568,13 +568,15 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the paymentIntent id AND reset status to pending. The
|
||||
// payment_intent.payment_failed webhook from the initial off-session decline
|
||||
// often arrives before this response and flips the order to failed —
|
||||
// override it back so the 3DS-in-flight order shows the correct state.
|
||||
// Persist the paymentIntent id AND set status to requires_action so list
|
||||
// UIs can distinguish "charging" from "awaiting customer 3DS". The
|
||||
// payment_intent.payment_failed webhook from the initial off-session
|
||||
// decline often arrives before this response and flips the order to
|
||||
// failed - override it back so the 3DS-in-flight order shows the
|
||||
// correct state
|
||||
if err := db.Model(order).Updates(map[string]any{
|
||||
"stripe_payment_id": pi.ID,
|
||||
"status": models.OrderStatusPending,
|
||||
"status": models.OrderStatusRequiresAction,
|
||||
}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||
return
|
||||
@@ -658,10 +660,14 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only pending (initial attempt mid-flight) and failed (webhook flipped it
|
||||
// after a non-recoverable error) are retryable. paid, refundedn shipped,
|
||||
// delivered, cancelled are terminal - refuse
|
||||
if order.Status != models.OrderStatusPending && order.Status != models.OrderStatusFailed {
|
||||
// Retryable source states:
|
||||
// pending - initial attempt mid-flight (pre-PI confirm page)
|
||||
// requires_action - customer abandoned or failed the 3DS challenge
|
||||
// failed - webhook flipped after a non-recoverable error
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
@@ -699,7 +705,7 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
@@ -707,8 +713,6 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
stripeStart := time.Now()
|
||||
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
|
||||
PaymentMethod: stripe.String(card.StripePaymentID),
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
@@ -68,7 +70,7 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -83,8 +85,8 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
Items []any `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -107,10 +109,10 @@ func TestSetItemAndGetCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
// Add item (CoffeeID=1, Quantity=2)
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"coffee_id": 1,
|
||||
"quantity": 2,
|
||||
})
|
||||
@@ -163,16 +165,16 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
// Update quantity
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
|
||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -208,16 +210,16 @@ func TestSetItemRemove(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
// Remove item (quantity 0)
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0})
|
||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -231,7 +233,7 @@ func TestSetItemRemove(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Items []any `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -247,9 +249,9 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"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)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -264,9 +266,9 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -281,11 +283,11 @@ func TestClearCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
// Add two items
|
||||
for _, coffeeID := range []uint{1, 2} {
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -309,7 +311,7 @@ func TestClearCart(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Items []any `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -337,9 +339,9 @@ func TestSetAddress(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -391,9 +393,9 @@ func TestSetAddressWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -419,9 +421,9 @@ func TestSetCard(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -471,9 +473,9 @@ func TestSetCardWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -488,10 +490,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
// Add item but no address or card
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -511,18 +513,18 @@ func TestConvertCartMissingCard(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
db := database.GetDB()
|
||||
|
||||
// Add item and address
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
|
||||
db.Create(&addr)
|
||||
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -542,7 +544,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
// Empty cart convert
|
||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
@@ -559,11 +561,11 @@ func TestMultipleItems(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
|
||||
// Add three different items
|
||||
for i := uint(1); i <= 3; i++ {
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -581,8 +583,8 @@ func TestMultipleItems(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
Items []any `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -608,19 +610,19 @@ func TestCartIsolation(t *testing.T) {
|
||||
}
|
||||
db.Create(&user2)
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
handler := NewCartHandler("", 0)
|
||||
r := chi.NewRouter()
|
||||
r.Put("/cart/item", handler.SetItem)
|
||||
r.Get("/cart", handler.GetCart)
|
||||
|
||||
// User 1 adds item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
|
||||
req := authRequest("PUT", "/cart/item", body, user1.ID)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// User 2 adds different item
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3})
|
||||
req = authRequest("PUT", "/cart/item", body, user2.ID)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -709,9 +711,17 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCartHandler("", "", tc.capCents)
|
||||
// Pin coffee_id=4's price so cart-cap math stays stable across
|
||||
// seed-price changes. The cap test is about (price * quantity)
|
||||
// vs cap, not which coffee or what the menu currently charges.
|
||||
const pinnedPrice = 500
|
||||
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", pinnedPrice).Error; err != nil {
|
||||
t.Fatalf("pin coffee price: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 4, "quantity": tc.quantity})
|
||||
handler := NewCartHandler("", tc.capCents)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -720,7 +730,7 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// set address
|
||||
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -729,7 +739,7 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// set card
|
||||
body, _ = json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
body, _ = json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -737,6 +747,19 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Capture audit slog ONLY for ConvertCart's emit window. Placed
|
||||
// here (not at top of t.Run) so setup INFO logs from
|
||||
// database.Connect/Migrate/Seed don't pollute the buffer with
|
||||
// non-audit records - json.Unmarshal below requires a single
|
||||
// JSON object, not multi-document input. slog.Default is
|
||||
// process-global - do NOT add t.Parallel() to subtests here.
|
||||
var auditBuf bytes.Buffer
|
||||
if tc.expectOverCap {
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&auditBuf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
}
|
||||
|
||||
// Convert
|
||||
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
@@ -745,9 +768,9 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
@@ -760,8 +783,29 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
|
||||
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
|
||||
}
|
||||
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*500 {
|
||||
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*500, resp.Error.Details["total_cents"])
|
||||
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
|
||||
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -786,8 +830,8 @@ func TestSetCard_ExpiredReturns410(t *testing.T) {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
handler := NewCartHandler("", 0)
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -824,20 +868,20 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"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)
|
||||
w := httptest.NewRecorder()
|
||||
NewCartHandler("", "", 0).SetItem(w, req)
|
||||
NewCartHandler("", 0).SetItem(w, req)
|
||||
|
||||
addr := models.Address{
|
||||
UserID: user.ID, Name: "Test", Street: "123 St",
|
||||
City: "PDX", State: "OR", Zip: "97201", Country: "US",
|
||||
}
|
||||
db.Create(&addr)
|
||||
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
NewCartHandler("", "", 0).SetAddress(w, req)
|
||||
NewCartHandler("", 0).SetAddress(w, req)
|
||||
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
card := models.Card{
|
||||
@@ -853,7 +897,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
|
||||
|
||||
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
NewCartHandler("", "", 0).ConvertCart(w, req)
|
||||
NewCartHandler("", 0).ConvertCart(w, req)
|
||||
|
||||
if w.Code != http.StatusGone {
|
||||
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
|
||||
@@ -875,8 +919,9 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
|
||||
// succeeded) hit the live Stripe API and aren't covered here — see
|
||||
// sca-psd2-compliance.md "Test plan" for manual coverage with the
|
||||
// 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies:
|
||||
// NewCartHandler takes 3 args and the seeded user must have a non-empty
|
||||
// StripeCustomerID so the Convert/Retry paths stay hermetic.
|
||||
// NewCartHandler takes 2 args (appURL, maxOrderCents) and the seeded user
|
||||
// must have a non-empty StripeCustomerID so the Convert/Retry paths stay
|
||||
// hermetic.
|
||||
func TestRetryAuth(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -911,6 +956,12 @@ func TestRetryAuth(t *testing.T) {
|
||||
seedPayment: "pi_test_refunded", seedEvents: 1,
|
||||
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",
|
||||
ownedByUser: true, seedStatus: models.OrderStatusFailed,
|
||||
@@ -977,7 +1028,7 @@ func TestRetryAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewCartHandler("", "http://localhost", 0)
|
||||
handler := NewCartHandler("http://localhost", 0)
|
||||
req := orderRequest("POST",
|
||||
fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1005,7 +1056,7 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
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")
|
||||
w := httptest.NewRecorder()
|
||||
handler.RetryAuth(w, req)
|
||||
@@ -1014,3 +1065,137 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
3
api/handlers/doc.go
Normal file
3
api/handlers/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package handlers is where the real work happens, one function per endpoint:
|
||||
// carts, orders, products, addresses, cards, webhooks, and the rest.
|
||||
package handlers
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
)
|
||||
|
||||
type OrderHandler struct {
|
||||
stripeKey string
|
||||
bringAPIUID string
|
||||
bringAPIKey string
|
||||
bringCustomerNumber string
|
||||
@@ -44,9 +43,10 @@ type refundRequestPayload struct {
|
||||
// repeated submissions from spamming the operator's Slack channel.
|
||||
const refundRequestCooldown = 15 * time.Minute
|
||||
|
||||
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
// NewOrderHandler creates an order handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
return &OrderHandler{
|
||||
stripeKey: stripeSecretKey,
|
||||
bringAPIUID: bringAPIUID,
|
||||
bringAPIKey: bringAPIKey,
|
||||
bringCustomerNumber: bringCustomerNumber,
|
||||
@@ -200,8 +200,6 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
rf, err := refund.New(&stripe.RefundParams{
|
||||
PaymentIntent: stripe.String(order.StripePaymentID),
|
||||
})
|
||||
@@ -221,7 +219,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"refund_id": rf.ID,
|
||||
"status": "refunded",
|
||||
})
|
||||
@@ -287,7 +285,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"carrier": req.Carrier,
|
||||
"tracking_number": req.TrackingNumber,
|
||||
|
||||
@@ -60,7 +60,7 @@ func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Contex
|
||||
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Other","message":" "}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -100,7 +100,7 @@ func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -142,7 +142,7 @@ func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -30,7 +30,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"products": products,
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
@@ -14,15 +17,19 @@ import (
|
||||
"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") }
|
||||
|
||||
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
|
||||
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
|
||||
// the case where the server crashed after charging but before the DB transaction
|
||||
// completed.
|
||||
func ReconcileOrders(stripeKey string) {
|
||||
stripe.Key = stripeKey
|
||||
db := database.GetDB()
|
||||
func ReconcileOrders(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
@@ -36,9 +43,14 @@ func ReconcileOrders(stripeKey string) {
|
||||
reconcileLog().Info("checking pending orders", "count", len(orders))
|
||||
|
||||
for _, order := range orders {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
params := &stripe.PaymentIntentSearchParams{
|
||||
SearchParams: stripe.SearchParams{
|
||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||
Context: ctx,
|
||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -49,7 +61,7 @@ func ReconcileOrders(stripeKey string) {
|
||||
"order_id", order.ID, "pi", pi.ID)
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
@@ -62,7 +74,7 @@ func ReconcileOrders(stripeKey string) {
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
return tx.Model(&cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -77,12 +89,117 @@ func ReconcileOrders(stripeKey string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Read paths filter out expired rows so users never see them; this job drives
|
||||
// the Stripe detach + audit + physical row delete out of band so request
|
||||
// handlers never block on Stripe API calls during a card sweep.
|
||||
func ReconcileExpiredCards(stripeKey string) {
|
||||
db := database.GetDB()
|
||||
func ReconcileExpiredCards(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var cards []models.Card
|
||||
if err := db.Where(
|
||||
@@ -98,16 +215,37 @@ func ReconcileExpiredCards(stripeKey string) {
|
||||
|
||||
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
|
||||
for i := range cards {
|
||||
if err := expireStoredCard(db, &cards[i], stripeKey); err != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
if err := expireStoredCard(db, &cards[i]); err != nil {
|
||||
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
|
||||
// number and posts single Slack reminder. Pure read + notif
|
||||
func ReconcileUnshipped() {
|
||||
db := database.GetDB()
|
||||
// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed.
|
||||
// Lazy deletion on read (PayRedirect) is the primary path, this sweep only
|
||||
// reclaims tokens nobody ever followed so the table stays bounded.
|
||||
func ReconcilePayRedirects(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
res := db.Where("expires_at < ?", time.Now()).Delete(&models.PayRedirect{})
|
||||
if res.Error != nil {
|
||||
reconcileLog().Error("pay_redirects sweep failed", "error", res.Error)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return
|
||||
}
|
||||
reconcileLog().Info("swept expired pay_redirects", "count", res.RowsAffected)
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid orders older than 24h with no tracking
|
||||
// number and posts a single Slack reminder. Pure read + notify.
|
||||
func ReconcileUnshipped(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
598
api/handlers/reconcile_test.go
Normal file
598
api/handlers/reconcile_test.go
Normal file
@@ -0,0 +1,598 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
)
|
||||
|
||||
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
|
||||
// and Stripe-Get branching: a row past the threshold whose PaymentIntent
|
||||
// Stripe still reports as requires_payment_method (the customer never
|
||||
// completed the challenge) transitions to failed and emits the new
|
||||
// order_3ds_abandoned audit row.
|
||||
func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_stale_abandoned",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
|
||||
before := testutil.ToFloat64(flipped)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusFailed {
|
||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
||||
}
|
||||
|
||||
var events []models.OrderEvent
|
||||
if err := db.Where("order_id = ? AND type = ?",
|
||||
order.ID, audit.EventOrder3DSAbandoned).Find(&events).Error; err != nil {
|
||||
t.Fatalf("list events: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
|
||||
}
|
||||
if got := testutil.ToFloat64(flipped) - before; got != 1 {
|
||||
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
|
||||
// excludes rows still within the grace window. A fresh requires_action row
|
||||
// must not even reach the Stripe Get call.
|
||||
func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_fresh_3ds",
|
||||
time.Now().Add(-5*time.Minute))
|
||||
|
||||
called := false
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
called = true
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
if called {
|
||||
t.Fatalf("stripe Get called for in-window row")
|
||||
}
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("status changed unexpectedly: %q", reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded covers the
|
||||
// webhook-race guard from caveat #20. If Stripe-side already says succeeded,
|
||||
// the sweep must defer to handlePaymentIntentSucceeded rather than overwrite
|
||||
// the authoritative end state.
|
||||
func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_succeeded_late_webhook",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusSucceeded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
|
||||
before := testutil.ToFloat64(skip)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(skip) - before; got != 1 {
|
||||
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
|
||||
// UPDATE. If the row flipped to paid between the Stripe Get and the flip,
|
||||
// the WHERE status = requires_action clause prevents clobber.
|
||||
func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_race_paid",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
db.Model(&models.Order{}).Where("id = ?", order.ID).
|
||||
Update("status", models.OrderStatusPaid)
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
|
||||
before := testutil.ToFloat64(noop)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(noop) - before; got != 1 {
|
||||
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
|
||||
// Stripe-terminal-canceled maps to OrderStatusFailed (matches RetryAuth's
|
||||
// existing precedent — see api/handlers/cart.go RetryAuth canceled branch).
|
||||
func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_canceled_stale",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusCanceled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusFailed {
|
||||
t.Fatalf("canceled PI must flip to failed; got %q", reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_DoesNotConsumeRetryCap regression-tests the
|
||||
// caveat #17 decoupling: the new sweep emits EventOrder3DSAbandoned, never
|
||||
// EventOrderRequiresAction, so a swept-then-stuck order does not silently
|
||||
// shrink the retry ceiling for any future user-driven retry attempts.
|
||||
func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_decoupled_cap",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var retryCount int64
|
||||
if err := db.Model(&models.OrderEvent{}).
|
||||
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
|
||||
Count(&retryCount).Error; err != nil {
|
||||
t.Fatalf("count retry events: %v", err)
|
||||
}
|
||||
if retryCount != 0 {
|
||||
t.Fatalf("sweep must not emit EventOrderRequiresAction; got %d", retryCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsTransitioningStatuses covers the
|
||||
// processing and requires_capture branches: Stripe still owns the PI's
|
||||
// next move, so the sweep must not flip even past the threshold.
|
||||
func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status stripe.PaymentIntentStatus
|
||||
}{
|
||||
{"processing", stripe.PaymentIntentStatusProcessing},
|
||||
{"requires_capture", stripe.PaymentIntentStatusRequiresCapture},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID,
|
||||
"pi_transitioning_"+tc.name, time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
|
||||
}
|
||||
|
||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
|
||||
before := testutil.ToFloat64(skip)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(skip) - before; got != 1 {
|
||||
t.Fatalf("want 1 processing_skip increment, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsUnknownStatus covers the switch's default
|
||||
// arm: an unrecognized Stripe status logs and skips rather than guessing a
|
||||
// transition. Defends against new PI statuses appearing in future SDKs.
|
||||
func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_unknown_status",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatus("some_new_future_status"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
|
||||
before := testutil.ToFloat64(unknown)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(unknown) - before; got != 1 {
|
||||
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
|
||||
// failure logs and leaves the row alone. Transient network errors must not
|
||||
// look like an abandoned 3DS.
|
||||
func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_stripe_error",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return nil, fmt.Errorf("simulated stripe outage")
|
||||
}
|
||||
|
||||
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
|
||||
before := testutil.ToFloat64(stripeErr)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
|
||||
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
|
||||
// defensive empty-PI short-circuit: even past the threshold, a row missing
|
||||
// stripe_payment_id must not reach paymentintent.Get (which would 4xx).
|
||||
func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_will_be_blanked",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
if err := db.Model(&order).UpdateColumn("stripe_payment_id", "").Error; err != nil {
|
||||
t.Fatalf("blank PI id: %v", err)
|
||||
}
|
||||
|
||||
called := false
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
called = true
|
||||
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
|
||||
}
|
||||
|
||||
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
|
||||
before := testutil.ToFloat64(missing)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
if called {
|
||||
t.Fatalf("stripe Get called for empty-PI row")
|
||||
}
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(missing) - before; got != 1 {
|
||||
t.Fatalf("want 1 missing_pi increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
|
||||
// ctx.Err() check stops the sweep once the supervising context cancels, so a
|
||||
// SIGTERM during a large stale-3DS batch does not block the binary past the
|
||||
// 30s graceful-shutdown window in api/main.go
|
||||
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
|
||||
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
calls := 0
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
cancel()
|
||||
}
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
ReconcileStale3DSOrders(ctx, 30*time.Minute)
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
|
||||
}
|
||||
var stillPending int64
|
||||
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
|
||||
t.Fatalf("count requires_action rows: %v", err)
|
||||
}
|
||||
if stillPending < 1 {
|
||||
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRequiresActionOrder inserts a card + order pair in the requires_action
|
||||
// state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of
|
||||
// UpdatedAt on Create does not bump the row out of the sweep predicate.
|
||||
func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt time.Time) models.Order {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
card := models.Card{
|
||||
UserID: userID,
|
||||
StripePaymentID: "pm_test_" + piID,
|
||||
Last4: "4242", Brand: "Visa",
|
||||
ExpMonth: 12, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
order := models.Order{
|
||||
UserID: userID, CardID: card.ID,
|
||||
StripePaymentID: piID,
|
||||
Status: models.OrderStatusRequiresAction,
|
||||
Subtotal: 500, Total: 500,
|
||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
||||
ShippingCity: "PDX", ShippingState: "OR",
|
||||
ShippingZip: "97201", ShippingCountry: "US",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
if err := db.Model(&order).UpdateColumn("updated_at", updatedAt).Error; err != nil {
|
||||
t.Fatalf("backdate updated_at: %v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// TestReconcilePayRedirects_DeleteExpiredKeepsLive verifies the predicate
|
||||
// boundary: a row past ExpiresAt is hard-deleted while a row whose ExpiresAt
|
||||
// is still in the future survives. Seeding both sides through GORM keeps the
|
||||
// stored timestamp format identical to the one the sweep binds for time.Now(),
|
||||
// so the comparison is exercised exactly as it runs in production.
|
||||
func TestReconcilePayRedirects_DeleteExpiredKeepsLive(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
now := time.Now()
|
||||
expired := models.PayRedirect{
|
||||
Token: "tok_expired",
|
||||
URL: "https://example.test/expired",
|
||||
Purpose: models.RedirectPurposeCheckout3DS,
|
||||
CreatedAt: now.Add(-20 * time.Minute),
|
||||
ExpiresAt: now.Add(-10 * time.Minute),
|
||||
}
|
||||
|
||||
live := models.PayRedirect{
|
||||
Token: "tok_live",
|
||||
URL: "https://example.test/live",
|
||||
Purpose: models.RedirectPurposeAddCard,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(10 * time.Minute),
|
||||
}
|
||||
if err := db.Create(&expired).Error; err != nil {
|
||||
t.Fatalf("seed expired: %v", err)
|
||||
}
|
||||
if err := db.Create(&live).Error; err != nil {
|
||||
t.Fatalf("seed live: %v", err)
|
||||
}
|
||||
|
||||
ReconcilePayRedirects(context.Background())
|
||||
|
||||
var remaining []models.PayRedirect
|
||||
if err := db.Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("list remaining: %v", err)
|
||||
}
|
||||
if len(remaining) != 1 {
|
||||
t.Fatalf("want 1 row remaining, got %d", len(remaining))
|
||||
}
|
||||
if remaining[0].Token != "tok_live" {
|
||||
t.Fatalf("wrong row survived: want tok_live, got %q", remaining[0].Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcilePayRedirects_NoExpiredIsNoOp(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
live := models.PayRedirect{
|
||||
Token: "tok_live_only",
|
||||
URL: "https://example.test/live",
|
||||
Purpose: models.RedirectPurposeCheckout3DS,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
if err := db.Create(&live).Error; err != nil {
|
||||
t.Fatalf("seed live: %v", err)
|
||||
}
|
||||
|
||||
ReconcilePayRedirects(context.Background())
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.PayRedirect{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("want 1 row preserved, got %d", count)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
"products": products,
|
||||
"cart": cartItems,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,13 +29,13 @@ func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
|
||||
// WebhookHandler handles inbound Stripe webhook events.
|
||||
type WebhookHandler struct {
|
||||
webhookSecret string
|
||||
stripeKey string
|
||||
shippoSecret string
|
||||
}
|
||||
|
||||
// NewWebhookHandler creates a new webhook handler.
|
||||
func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler {
|
||||
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret}
|
||||
// NewWebhookHandler creates a new webhook handler. Stripe credentials are
|
||||
// wired once at startup via stripe.Key in api/main.go.
|
||||
func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler {
|
||||
return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret}
|
||||
}
|
||||
|
||||
// HandleStripe validates the Stripe signature and routes the event.
|
||||
@@ -52,8 +53,17 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Verify the Stripe signature. Reject anything that doesn't match.
|
||||
if h.webhookSecret == "" {
|
||||
// No secret configured — log a loud warning but still process in dev.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
|
||||
// Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
|
||||
// in production, so the server should never boot into this state; this is
|
||||
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
|
||||
// Slack handlers, which already refuse to run without their secret.
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
|
||||
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
|
||||
return
|
||||
}
|
||||
// Development only; warn loudly but still process so local testing works.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
|
||||
} else {
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
|
||||
@@ -124,8 +134,22 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
return
|
||||
}
|
||||
|
||||
// Defense in depth: even behind a valid signature, refuse to mark an order
|
||||
// paid unless the PaymentIntent's amount and currency match what we charged.
|
||||
// Under ConvertCart these always match (Amount == order.Total at creation), so
|
||||
// a mismatch means a malformed or unexpected event we should not trust.
|
||||
if pi.Amount != int64(order.Total) {
|
||||
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
|
||||
return
|
||||
}
|
||||
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
|
||||
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
|
||||
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
|
||||
return
|
||||
}
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
@@ -139,7 +163,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete cart item: %w", err)
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
return tx.Model(&cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -184,7 +208,7 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
|
||||
if err := db.Model(&models.Order{}).Where("id = ? AND status IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction).
|
||||
Update("status", models.OrderStatusFailed).Error; err != nil {
|
||||
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
|
||||
}
|
||||
@@ -212,8 +236,6 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
|
||||
if err != nil {
|
||||
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)
|
||||
|
||||
259
api/handlers/webhook_test.go
Normal file
259
api/handlers/webhook_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
|
||||
// a PaymentIntent referencing the given order via metadata. errCode is set on
|
||||
// last_payment_error to drive the authentication_required short-circuit
|
||||
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
|
||||
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
|
||||
t.Helper()
|
||||
pi := map[string]any{
|
||||
"id": piID,
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
|
||||
}
|
||||
if errCode != "" {
|
||||
pi["last_payment_error"] = map[string]any{"code": errCode}
|
||||
}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
return stripe.Event{
|
||||
Type: "payment_intent.payment_failed",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
}
|
||||
|
||||
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
card := models.Card{
|
||||
UserID: userID,
|
||||
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
|
||||
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
order := models.Order{
|
||||
UserID: userID, CardID: card.ID,
|
||||
StripePaymentID: piID,
|
||||
Status: status,
|
||||
Subtotal: 500, Total: 500,
|
||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
||||
ShippingCity: "PDX", ShippingState: "OR",
|
||||
ShippingZip: "97201", ShippingCountry: "US",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
|
||||
// widened predicate (IN (pending, requires_action)) catches a row in the new
|
||||
// requires_action state. Without this case, a card_declined webhook arriving
|
||||
// after respondRequiresAction wrote requires_action would leave the row
|
||||
// stuck forever.
|
||||
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_failed")
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusFailed {
|
||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
|
||||
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
|
||||
// still fires when the row is in requires_action and the error code is
|
||||
// authentication_required. Without the short-circuit, the recovery flow
|
||||
// would race the webhook and surface a momentarily-failed state to the TUI.
|
||||
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_authreq")
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
|
||||
// pending -> failed transition against a regression where the widened
|
||||
// predicate accidentally dropped pending from the source set.
|
||||
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusFailed {
|
||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
|
||||
// the predicate: a paid row must NEVER be flipped to failed by a stale
|
||||
// payment_failed webhook. Without this guard, a future maintainer could
|
||||
// widen the IN clause to include paid (e.g. for some refund-retry flow)
|
||||
// and silently break idempotency.
|
||||
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Fatalf("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
|
||||
// short-circuit so the test file exercises every early-return branch in
|
||||
// handlePaymentIntentFailed touched by the predicate work.
|
||||
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
|
||||
testDB, _ := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
pi := map[string]any{"id": "pi_test_no_meta"}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
evt := stripe.Event{
|
||||
Type: "payment_intent.payment_failed",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt) // must not panic
|
||||
}
|
||||
|
||||
func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid")
|
||||
|
||||
pi := map[string]any{
|
||||
"id": order.StripePaymentID,
|
||||
"amount": order.Total,
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||
}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
evt := stripe.Event{
|
||||
Type: "payment_intent.succeeded",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Fatalf("status: want %q, got %q", models.OrderStatusPaid, reloaded.Status)
|
||||
}
|
||||
if reloaded.StripePaymentID != order.StripePaymentID {
|
||||
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
|
||||
|
||||
// PI claims 1 cent; seed order.Total is 500.
|
||||
pi := map[string]any{
|
||||
"id": order.StripePaymentID,
|
||||
"amount": 1,
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||
}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
evt := stripe.Event{
|
||||
Type: "payment_intent.succeeded",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reloaded order: %v", err)
|
||||
}
|
||||
if reloaded.Status == models.OrderStatusPaid {
|
||||
t.Fatalf("order marked paid despite amount mismatch")
|
||||
}
|
||||
}
|
||||
86
api/main.go
86
api/main.go
@@ -19,10 +19,31 @@ import (
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/observability"
|
||||
"terminalShop/pkg/stripeclient"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
)
|
||||
|
||||
const version = "v0.1.0"
|
||||
|
||||
// everyTick runs fn on its own goroutine every d until ctx is cancelled.
|
||||
// Shared by the reconciler sweeps so the ticker + shutdown plumbing lives in
|
||||
// one place. ReconcileUnshipped intentionally does not use it, it needs a
|
||||
// staggered warm-up run before its first tick.
|
||||
func everyTick(ctx context.Context, d time.Duration, fn func(context.Context)) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(d)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
fn(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func main() {
|
||||
observability.InitLogger()
|
||||
middleware.SetBuildInfo(version)
|
||||
@@ -49,6 +70,12 @@ func main() {
|
||||
}
|
||||
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
|
||||
if err := database.Seed(db); err != nil {
|
||||
log.Fatalf("Failed to seed database: %v", err)
|
||||
@@ -71,7 +98,7 @@ func main() {
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
// Setup routes
|
||||
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)
|
||||
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)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
@@ -98,30 +125,22 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Start reconciliation job. runs every 5 minutes to catch any orders
|
||||
// that were charged but not recorded due to a crash.
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileOrders(cfg.StripeSecretKey)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Catch orders charged but not recorded due to a crash. Every 5 minutes
|
||||
everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders)
|
||||
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
handlers.ReconcileUnshipped()
|
||||
select {
|
||||
case <-time.After(30 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
handlers.ReconcileUnshipped(ctx)
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileUnshipped()
|
||||
handlers.ReconcileUnshipped(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -130,19 +149,18 @@ func main() {
|
||||
|
||||
// Sweep saved cards whose retention deadline elapsed. Read paths filter
|
||||
// expired rows from query results, so this sweeper only drives the Stripe
|
||||
// detach + audit + physical row delete asynchronously.
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileExpiredCards(cfg.StripeSecretKey)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// detach + audit + physical row delete asynchronously. Hourly.
|
||||
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards)
|
||||
|
||||
// Flip abandoned 3DS orders to failed. Every 15 minutes.
|
||||
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
|
||||
everyTick(ctx, 15*time.Minute, func(ctx context.Context) {
|
||||
handlers.ReconcileStale3DSOrders(ctx, threshold)
|
||||
})
|
||||
|
||||
// Sweep expired pay_redirects rows so the table stays bounded. Lazy delete
|
||||
// on read is the primary path; this only reclaims tokens nobody follows.
|
||||
everyTick(ctx, 10*time.Minute, handlers.ReconcilePayRedirects)
|
||||
|
||||
log.Println("API server started. Press Ctrl+C to shutdown.")
|
||||
|
||||
@@ -152,10 +170,10 @@ func main() {
|
||||
log.Println("Server is shutting down...")
|
||||
|
||||
// Graceful shutdown with 30 second timeout
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
|
||||
4
api/middleware/doc.go
Normal file
4
api/middleware/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package middleware holds the stuff that runs on every request before a
|
||||
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
|
||||
// recovery net for panics.
|
||||
package middleware
|
||||
@@ -70,6 +70,14 @@ var (
|
||||
[]string{"outcome"},
|
||||
)
|
||||
|
||||
abandoned3DSSweepTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "abandoned_3ds_sweep_total",
|
||||
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
|
||||
},
|
||||
[]string{"outcome"},
|
||||
)
|
||||
|
||||
orderValueDollars = promauto.NewHistogram(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "order_value_dollars",
|
||||
@@ -173,6 +181,19 @@ func ObserveOrderValueCents(cents int) {
|
||||
orderValueDollars.Observe(float64(cents) / 100.0)
|
||||
}
|
||||
|
||||
// RecordAbandoned3DSSweep records the outcome of one row processed by
|
||||
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
|
||||
// Help string.
|
||||
func RecordAbandoned3DSSweep(outcome string) {
|
||||
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
|
||||
}
|
||||
|
||||
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
|
||||
// per-label values via prometheus testutil. Not used in production code.
|
||||
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
|
||||
return abandoned3DSSweepTotal
|
||||
}
|
||||
|
||||
// ObserveStripeRequest records latency of a Stripe API call.
|
||||
func ObserveStripeRequest(op, outcome string, durSec float64) {
|
||||
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)
|
||||
|
||||
3
api/routes/doc.go
Normal file
3
api/routes/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package routes is the switchboard: it maps every URL to the handler that
|
||||
// knows what to do with it.
|
||||
package routes
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
// SetupRoutes configures all API routes
|
||||
func SetupRoutes(
|
||||
version string,
|
||||
stripeSecretKey string,
|
||||
stripeWebhookSecret string,
|
||||
jwtManager *auth.JWTManager,
|
||||
authFingerprintKey string,
|
||||
@@ -47,12 +46,12 @@ func SetupRoutes(
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
productHandler := handlers.NewProductHandler()
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
cartHandler := handlers.NewCartHandler(appURL, maxOrderCents)
|
||||
cardHandler := handlers.NewCardHandler(appURL)
|
||||
orderHandler := handlers.NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||
viewHandler := handlers.NewViewHandler()
|
||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
|
||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
|
||||
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
|
||||
|
||||
// Short payment redirect and success page — no auth required.
|
||||
|
||||
3
cmd/reconcile-cards/doc.go
Normal file
3
cmd/reconcile-cards/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
|
||||
// about and detaches them. Run it with -dry-run first if you're nervous.
|
||||
package main
|
||||
1
go.mod
1
go.mod
@@ -81,6 +81,7 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
14
main.go
14
main.go
@@ -15,6 +15,7 @@ import (
|
||||
"charm.land/wish/v2/bubbletea"
|
||||
"charm.land/wish/v2/logging"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
@@ -31,7 +32,6 @@ func listenHost() string {
|
||||
var (
|
||||
apiURL string
|
||||
authFingerprintKey string
|
||||
stripePublicKey string
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -57,7 +57,15 @@ func main() {
|
||||
// Set API URL and auth key from config for TUI connections
|
||||
apiURL = cfg.APIURL
|
||||
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)
|
||||
|
||||
// Create SSH server
|
||||
|
||||
@@ -104,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
|
||||
|
||||
// APIResponse represents the standard API response format
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Data map[string]any `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// APIError represents an error from the API
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ProductsResponse represents the products API response
|
||||
@@ -339,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
||||
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"coffee_id": coffeeID,
|
||||
"quantity": quantity,
|
||||
}
|
||||
@@ -379,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
func (c *Client) SetCartAddress(addressID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"address_id": addressID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -418,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
||||
func (c *Client) SetCartCard(cardID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"card_id": cardID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
|
||||
3
pkg/api/doc.go
Normal file
3
pkg/api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package api is the client side of the house: it talks to our HTTP backend
|
||||
// so other Go code doesn't have to hand-roll requests.
|
||||
package api
|
||||
@@ -14,10 +14,22 @@ import (
|
||||
)
|
||||
|
||||
// EventOrderRequiresAction is the audit event-type string emitted whenever a
|
||||
// PaymentIntent is pushed into requires_action. explored so cart-flow handler
|
||||
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler
|
||||
// can count retry attempts without hardcoding the string literal.
|
||||
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
|
||||
|
||||
const (
|
||||
@@ -29,6 +41,8 @@ const (
|
||||
eventOrderShipped eventType = "order_shipped"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRequiresAction eventType = EventOrderRequiresAction
|
||||
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
|
||||
eventCartRejected eventType = EventCartRejected
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
@@ -39,19 +53,21 @@ const (
|
||||
)
|
||||
|
||||
type event struct {
|
||||
Event eventType
|
||||
UserID uint
|
||||
OrderID uint
|
||||
CardID uint
|
||||
Amount int
|
||||
StripeID string
|
||||
CardLast4 string
|
||||
CardBrand string
|
||||
Carrier string
|
||||
Tracking string
|
||||
Status string
|
||||
Error string
|
||||
Actor string
|
||||
Event eventType
|
||||
UserID uint
|
||||
OrderID uint
|
||||
CardID uint
|
||||
Amount int
|
||||
StripeID string
|
||||
CardLast4 string
|
||||
CardBrand string
|
||||
Carrier string
|
||||
Tracking string
|
||||
Status string
|
||||
Error string
|
||||
Actor string
|
||||
TotalCents int
|
||||
CapCents int
|
||||
}
|
||||
|
||||
func (e event) attrs() []any {
|
||||
@@ -92,6 +108,12 @@ func (e event) attrs() []any {
|
||||
if e.Actor != "" {
|
||||
a = append(a, "actor", e.Actor)
|
||||
}
|
||||
if e.TotalCents != 0 {
|
||||
a = append(a, "total_cents", e.TotalCents)
|
||||
}
|
||||
if e.CapCents != 0 {
|
||||
a = append(a, "cap_cents", e.CapCents)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -328,3 +350,33 @@ func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||
slog.Info("audit", e.attrs()...)
|
||||
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()...)
|
||||
}
|
||||
|
||||
41
pkg/audit/audit_test.go
Normal file
41
pkg/audit/audit_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCartRejected(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
CartRejected(42, 25000, 20000)
|
||||
|
||||
raw := strings.TrimSpace(buf.String())
|
||||
if raw == "" {
|
||||
t.Fatalf("CartRejected: no slog output captured")
|
||||
}
|
||||
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &rec); err != nil {
|
||||
t.Fatalf("decode slog record: %v\nraw: %s", err, raw)
|
||||
}
|
||||
|
||||
if got, want := rec["event"], "cart_rejected"; got != want {
|
||||
t.Errorf("event: want %q, got %v", want, got)
|
||||
}
|
||||
if n, _ := rec["user_id"].(float64); int(n) != 42 {
|
||||
t.Errorf("user_id want 42, got %v", rec["user_id"])
|
||||
}
|
||||
if n, _ := rec["total_cents"].(float64); int(n) != 25000 {
|
||||
t.Errorf("total_cents: want 25000, got %v", rec["total_cents"])
|
||||
}
|
||||
if n, _ := rec["cap_cents"].(float64); int(n) != 20000 {
|
||||
t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"])
|
||||
}
|
||||
}
|
||||
3
pkg/auth/doc.go
Normal file
3
pkg/auth/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package auth sorts out who you are: logging in with an SSH key and minting
|
||||
// the JWT you carry around afterwards.
|
||||
package auth
|
||||
@@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string,
|
||||
|
||||
// ValidateToken validates a JWT token and returns the claims
|
||||
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
// Verify signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
|
||||
3
pkg/bring/doc.go
Normal file
3
pkg/bring/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package bring talks to Bring, the Norwegian postal service, for booking
|
||||
// shipments and printing labels.
|
||||
package bring
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -13,25 +14,26 @@ import (
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
APIPort string
|
||||
APIURL string
|
||||
AppURL string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
StripeSecretKey string
|
||||
StripePublicKey string
|
||||
StripeWebhookSecret string
|
||||
ShippoAPIKey string
|
||||
AuthFingerprintKey string
|
||||
BringAPIUID string
|
||||
BringAPIKey string
|
||||
BringCustomerNumber string
|
||||
ShippoWebhookSecret string
|
||||
SlackSigningSecret string
|
||||
AdminAPIKey string
|
||||
MaxOrderCents int
|
||||
APIPort string
|
||||
APIURL string
|
||||
AppURL string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
StripeSecretKey string
|
||||
StripePublicKey string
|
||||
StripeWebhookSecret string
|
||||
ShippoAPIKey string
|
||||
AuthFingerprintKey string
|
||||
BringAPIUID string
|
||||
BringAPIKey string
|
||||
BringCustomerNumber string
|
||||
ShippoWebhookSecret string
|
||||
SlackSigningSecret string
|
||||
AdminAPIKey string
|
||||
MaxOrderCents int
|
||||
Abandoned3DSThresholdMinutes int
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and validates required secrets.
|
||||
@@ -50,25 +52,26 @@ func Load() (*Config, error) {
|
||||
apiPort := getEnvOrDefault("API_PORT", "8080")
|
||||
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
|
||||
cfg := &Config{
|
||||
APIPort: apiPort,
|
||||
APIURL: apiURL,
|
||||
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||
MaxOrderCents: loadMaxOrderCents(),
|
||||
APIPort: apiPort,
|
||||
APIURL: apiURL,
|
||||
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||
MaxOrderCents: loadMaxOrderCents(),
|
||||
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
@@ -89,6 +92,7 @@ func (c *Config) validate() error {
|
||||
{"JWT_SECRET", c.JWTSecret},
|
||||
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
|
||||
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
|
||||
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
|
||||
}
|
||||
|
||||
var missing []string
|
||||
@@ -134,11 +138,13 @@ func getEnvOrDefault(key, defaultValue string) string {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
|
||||
// the cap. Garbage or negative values fall back to the default with a warning
|
||||
// so an operator typo cannot silently disable a fraud control.
|
||||
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the
|
||||
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the
|
||||
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or
|
||||
// negative values fall back to the default with a warning so an operator
|
||||
// typo cannot silently disable a fraud control.
|
||||
func loadMaxOrderCents() int {
|
||||
const defaultCap = 20000
|
||||
const defaultCap = 50000
|
||||
raw := os.Getenv("MAX_ORDER_CENTS")
|
||||
if raw == "" {
|
||||
return defaultCap
|
||||
@@ -150,3 +156,26 @@ func loadMaxOrderCents() int {
|
||||
}
|
||||
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,21 +1,35 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"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) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string // "" means unset
|
||||
want int
|
||||
}{
|
||||
{"unset uses default", "", 20000},
|
||||
{"empty falls back to default", "", 50000},
|
||||
{"explicit zero disables", "0", 0},
|
||||
{"valid override", "50000", 50000},
|
||||
{"negative falls back to default", "-1", 20000},
|
||||
{"garbage falls back to default", "two hundred", 20000},
|
||||
{"empty after trim falls back to default", "", 20000},
|
||||
{"valid override", "30000", 30000},
|
||||
{"negative falls back to default", "-1", 50000},
|
||||
{"garbage falls back to default", "two hundred", 50000},
|
||||
{"empty after trim falls back to default", "", 50000},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -32,3 +46,33 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
3
pkg/config/doc.go
Normal file
3
pkg/config/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package config reads everything out of the environment so secrets and
|
||||
// settings stay out of the source code.
|
||||
package config
|
||||
3
pkg/database/doc.go
Normal file
3
pkg/database/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package database opens the SQLite connection, runs the migrations, and
|
||||
// drops in some seed data so the shop isn't empty on a fresh boot.
|
||||
package database
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Seed populates the database with initial data
|
||||
@@ -25,54 +26,60 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Espresso",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 5,
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 350, // $3.50
|
||||
Price: 1800, // $18.00
|
||||
Color: "#8B4513",
|
||||
Description: "Bold and intense coffee with a rich crema",
|
||||
},
|
||||
{
|
||||
Name: "Americano",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 8,
|
||||
BeanType: "Arabica",
|
||||
Price: 400, // $4.00
|
||||
Price: 2200, // $22.00
|
||||
Color: "#A0522D",
|
||||
Description: "Smooth and balanced espresso diluted with hot water",
|
||||
},
|
||||
{
|
||||
Name: "Cappuccino",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 6,
|
||||
BeanType: "Arabica",
|
||||
Price: 450, // $4.50
|
||||
Price: 2200, // $22.00
|
||||
Color: "#C4A574",
|
||||
Description: "Espresso with steamed milk and a thick layer of foam",
|
||||
},
|
||||
{
|
||||
Name: "Latte",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 2,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 500, // $5.00
|
||||
Price: 2500, // $25.00
|
||||
Color: "#D2B48C",
|
||||
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
|
||||
},
|
||||
{
|
||||
Name: "Mocha",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 550, // $5.50
|
||||
Price: 3000, // $30.00
|
||||
Color: "#8B4513",
|
||||
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
|
||||
},
|
||||
{
|
||||
Name: "Macchiato",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 3,
|
||||
BeanType: "Robusta Blend",
|
||||
Price: 425, // $4.25
|
||||
Price: 1800, // $18.00
|
||||
Color: "#DAA520",
|
||||
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ type Coffee struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
RoastType string `gorm:"size:100" json:"roast_type"`
|
||||
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
|
||||
Ounces int `json:"ounces"`
|
||||
BeanType string `gorm:"size:100" json:"bean_type"`
|
||||
Price int `gorm:"not null" json:"price"`
|
||||
|
||||
3
pkg/models/doc.go
Normal file
3
pkg/models/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package models is the shared vocabulary of the app: coffee, carts, orders,
|
||||
// addresses, cards, users, and the other nouns everything passes around.
|
||||
package models
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
type OrderStatus string
|
||||
|
||||
const (
|
||||
OrderStatusPending OrderStatus = "pending"
|
||||
OrderStatusPaid OrderStatus = "paid"
|
||||
OrderStatusShipped OrderStatus = "shipped"
|
||||
OrderStatusDelivered OrderStatus = "delivered"
|
||||
OrderStatusCancelled OrderStatus = "cancelled"
|
||||
OrderStatusRefunded OrderStatus = "refunded"
|
||||
OrderStatusFailed OrderStatus = "failed"
|
||||
OrderStatusPending OrderStatus = "pending"
|
||||
OrderStatusRequiresAction OrderStatus = "requires_action"
|
||||
OrderStatusPaid OrderStatus = "paid"
|
||||
OrderStatusShipped OrderStatus = "shipped"
|
||||
OrderStatusDelivered OrderStatus = "delivered"
|
||||
OrderStatusCancelled OrderStatus = "cancelled"
|
||||
OrderStatusRefunded OrderStatus = "refunded"
|
||||
OrderStatusFailed OrderStatus = "failed"
|
||||
)
|
||||
|
||||
type TrackingStatus string
|
||||
|
||||
@@ -35,6 +35,8 @@ func (order *Order) DisplayState() string {
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return "Awaiting payment"
|
||||
case OrderStatusRequiresAction:
|
||||
return "Awaiting authentication"
|
||||
case OrderStatusPaid:
|
||||
if order.TrackingNumber == "" {
|
||||
return "Paid * awaiting label"
|
||||
@@ -77,6 +79,8 @@ func (order *Order) DisplayKind() DisplayKind {
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusRequiresAction:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusPaid:
|
||||
return DisplayKindAccent
|
||||
case OrderStatusShipped:
|
||||
|
||||
@@ -14,6 +14,7 @@ func TestOrderDisplayState(t *testing.T) {
|
||||
kind DisplayKind
|
||||
}{
|
||||
{"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 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},
|
||||
|
||||
3
pkg/observability/doc.go
Normal file
3
pkg/observability/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package observability sets up tracing and structured logs so that when
|
||||
// something breaks at 3am you stand a chance of figuring out why.
|
||||
package observability
|
||||
3
pkg/shipping/doc.go
Normal file
3
pkg/shipping/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package shipping holds the plain shipping types that the rest of the app
|
||||
// passes back and forth.
|
||||
package shipping
|
||||
2
pkg/shippo/doc.go
Normal file
2
pkg/shippo/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package shippo talks to the Shippo API for shipping rates and labels.
|
||||
package shippo
|
||||
3
pkg/stripeclient/doc.go
Normal file
3
pkg/stripeclient/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package stripeclient wires up the Stripe SDK once at startup (with tracing
|
||||
// baked in) so everyone else can just make calls.
|
||||
package stripeclient
|
||||
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
@@ -8,36 +10,54 @@ import (
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// updateAccountViewport initializes or resizes the detail viewport for the account page.
|
||||
// Follows the reference pattern: each page owns its own viewport(s).
|
||||
func (m Model) updateAccountViewport() Model {
|
||||
// Match the reference: only subtract header + footer, no extra margins
|
||||
// (account is a viewport page — views.go renders it without margin padding)
|
||||
// accountBodyHeight returns the inner content height of the account body
|
||||
// panels. The body is quantized to whole order cards so the menu panel's
|
||||
// bottom border lines up exactly with the last visible card; the two gap
|
||||
// rows around the body host the up/down scroll arrows.
|
||||
func (m Model) accountBodyHeight() int {
|
||||
headerHeight := lipgloss.Height(m.BuildHeader())
|
||||
footerHeight := lipgloss.Height(m.BuildFooter())
|
||||
verticalMarginHeight := headerHeight + footerHeight
|
||||
|
||||
availableHeight := m.heightContainer - verticalMarginHeight
|
||||
|
||||
leftWidth := 18
|
||||
rightWidth := m.widthContent - leftWidth - 2
|
||||
detailWidth := rightWidth
|
||||
if m.size < large {
|
||||
detailWidth = m.widthContent
|
||||
// In stacked mode, reduce available height by the left panel
|
||||
leftPanelHeight := len(models.AccountMenuItems) + 1
|
||||
availableHeight -= leftPanelHeight
|
||||
budget := m.heightContainer - headerHeight - footerHeight - 2
|
||||
cards := budget / orderCardHeight
|
||||
if cards < 1 {
|
||||
cards = 1
|
||||
}
|
||||
if availableHeight < 3 {
|
||||
availableHeight = 3
|
||||
return cards*orderCardHeight - 2
|
||||
}
|
||||
|
||||
// accountDetailWidth returns the width of the unboxed detail area: the chrome
|
||||
// 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.
|
||||
// The viewport spans the unboxed detail area to the right of the menu panel.
|
||||
func (m Model) updateAccountViewport() Model {
|
||||
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 {
|
||||
// Stacked: the menu panel sits above the detail area
|
||||
menuPanelHeight := len(models.AccountMenuItems) + 2
|
||||
availableHeight -= menuPanelHeight
|
||||
if availableHeight < 3 {
|
||||
availableHeight = 3
|
||||
}
|
||||
}
|
||||
|
||||
if !m.account.viewportReady {
|
||||
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
|
||||
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight))
|
||||
m.account.detailViewport.KeyMap = modifiedKeyMap
|
||||
m.account.viewportReady = true
|
||||
} else {
|
||||
m.account.detailViewport.SetWidth(detailWidth)
|
||||
m.account.detailViewport.SetWidth(detailW)
|
||||
m.account.detailViewport.SetHeight(availableHeight)
|
||||
}
|
||||
|
||||
@@ -46,12 +66,7 @@ func (m Model) updateAccountViewport() Model {
|
||||
|
||||
// getAccountDetailContent generates the content string for the current detail panel.
|
||||
func (m Model) getAccountDetailContent() string {
|
||||
leftWidth := 18
|
||||
rightWidth := m.widthContent - leftWidth - 2
|
||||
detailContentWidth := rightWidth - 2
|
||||
if m.size < large {
|
||||
detailContentWidth = m.widthContent - 2
|
||||
}
|
||||
detailContentWidth := m.accountDetailWidth()
|
||||
|
||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||
return ""
|
||||
@@ -68,7 +83,6 @@ func (m Model) getAccountDetailContent() string {
|
||||
case "cards":
|
||||
return m.CardsView(detailContentWidth)
|
||||
case "faq":
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
questionStyle := m.theme.TextHighlight().Bold(true)
|
||||
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
||||
faqContent := ""
|
||||
@@ -79,7 +93,7 @@ func (m Model) getAccountDetailContent() string {
|
||||
faqContent += "\n\n"
|
||||
}
|
||||
}
|
||||
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||
detailView := faqContent
|
||||
if !m.account.faqFocused {
|
||||
hintStyle := m.theme.TextDim()
|
||||
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
||||
@@ -103,19 +117,25 @@ func (m Model) scrollToAccountDetailItem() Model {
|
||||
|
||||
switch {
|
||||
case m.account.orderViewState == 1:
|
||||
// No title in list mode, cards joined with JoinVertical (no gaps)
|
||||
// Card: border(2) + content(2) + PaddingLeft only = 4 lines
|
||||
headerOffset = 0
|
||||
itemHeight = 4
|
||||
itemCount = len(m.currentOrderList())
|
||||
selectedIndex = m.account.orderCursor
|
||||
// Order list uses whole-card windowing instead of viewport scroll:
|
||||
// shift the window just enough to keep the cursor's card visible.
|
||||
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:
|
||||
headerOffset = 3
|
||||
// Titles were removed from the detail views, lists start at the top
|
||||
headerOffset = 0
|
||||
itemHeight = 5
|
||||
itemCount = len(m.SavedAddresses)
|
||||
selectedIndex = m.account.addressCursor
|
||||
case m.account.cardListFocused:
|
||||
headerOffset = 3
|
||||
headerOffset = 0
|
||||
itemHeight = 4
|
||||
itemCount = len(m.SavedCards)
|
||||
selectedIndex = m.account.cardCursor
|
||||
@@ -143,23 +163,30 @@ func (m Model) scrollToAccountDetailItem() Model {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) BuildAccountView(availableHeight int) string {
|
||||
leftWidth := 18
|
||||
func (m Model) BuildAccountView() string {
|
||||
_, menuW, _, _ := m.shopLayout()
|
||||
|
||||
// Build the left panel (menu items)
|
||||
leftPanel := ""
|
||||
// Left panel: menu items. The highlight is blue while the user navigates
|
||||
// the menu, and falls back to the gray pill when focus has moved into the
|
||||
// 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 {
|
||||
if m.account.cursor == i {
|
||||
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
|
||||
leftPanel += style.Render(item) + "\n"
|
||||
menu = append(menu, lipgloss.NewStyle().Width(menuW).
|
||||
Background(pillBg).Foreground(cWhite).Bold(true).
|
||||
Render(" "+item))
|
||||
} else {
|
||||
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
|
||||
leftPanel += style.Render(item) + "\n"
|
||||
menu = append(menu, lipgloss.NewStyle().Width(menuW).
|
||||
Render(" "+pBody.Render(item)))
|
||||
}
|
||||
}
|
||||
|
||||
leftContainer := m.theme.Base().
|
||||
Width(leftWidth)
|
||||
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
|
||||
|
||||
// Generate detail content and set it on the viewport
|
||||
detailContent := m.getAccountDetailContent()
|
||||
@@ -168,25 +195,43 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
||||
// Viewport handles scrolling/clipping — no manual line-slicing needed
|
||||
detailRendered := m.account.detailViewport.View()
|
||||
|
||||
// Responsive layout: side-by-side on large, stacked on small/medium
|
||||
// Responsive layout: menu panel beside the unboxed detail area on large,
|
||||
// stacked on small/medium. The detail side draws its own item boxes.
|
||||
var body string
|
||||
if m.size < large {
|
||||
return lipgloss.JoinVertical(
|
||||
body = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
leftContainer.Render(leftPanel),
|
||||
panel(menuW, 0, menuContent),
|
||||
detailRendered,
|
||||
)
|
||||
} else {
|
||||
bodyH := m.accountBodyHeight()
|
||||
body = lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
panel(menuW, bodyH, menuContent),
|
||||
" ",
|
||||
detailRendered,
|
||||
)
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
leftContainer.Render(leftPanel),
|
||||
" ",
|
||||
detailRendered,
|
||||
)
|
||||
// Blank rows separate the body from the header and footer boxes. When the
|
||||
// windowed order list has hidden items, the arrows occupy those gap rows,
|
||||
// centered over the card column, so the cards themselves always span
|
||||
// exactly the menu panel's rows — top and bottom borders aligned.
|
||||
topGap := ""
|
||||
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 {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
// USE this if wanting to add a corp name later
|
||||
// accentStyle := m.theme.TextHighlight().Bold(true)
|
||||
@@ -208,7 +253,7 @@ func (m Model) AboutView(width int) string {
|
||||
width,
|
||||
)) + "\n"
|
||||
|
||||
return titleStyle.Render("About") + "\n\n" + aboutContent
|
||||
return aboutContent
|
||||
}
|
||||
|
||||
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
@@ -335,6 +380,7 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
case 0:
|
||||
m.account.orderViewState = 1
|
||||
m.account.orderCursor = 0
|
||||
m.account.orderWindowStart = 0
|
||||
m.account.detailViewport.GotoTop()
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "orders"},
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package tui
|
||||
|
||||
func (m Model) AddressesView(width int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
|
||||
lines := titleStyle.Render("Address") + "\n\n"
|
||||
lines := ""
|
||||
if len(m.SavedAddresses) == 0 {
|
||||
lines += contentStyle.Render("No saved addresses")
|
||||
} else {
|
||||
|
||||
@@ -5,10 +5,9 @@ import (
|
||||
)
|
||||
|
||||
func (m Model) CardsView(width int) string {
|
||||
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
|
||||
lines := titleSTyle.Render("Cards") + "\n\n"
|
||||
lines := ""
|
||||
if m.SavedCardsIsEmpty() {
|
||||
lines += contentStyle.Render("No saved cards")
|
||||
} else {
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func (m Model) OrdersView(width int) string {
|
||||
return m.orderListView("Order History", m.HistoryOrders(), width)
|
||||
return m.orderListView(m.HistoryOrders(), width)
|
||||
}
|
||||
|
||||
func (m Model) ActiveOrdersView(width int) string {
|
||||
return m.orderListView("Active Orders", m.ActiveOrders(), width)
|
||||
return m.orderListView(m.ActiveOrders(), width)
|
||||
}
|
||||
|
||||
func (m Model) ActiveOrders() []models.Order {
|
||||
@@ -65,41 +65,95 @@ func (m Model) currentOrderList() []models.Order {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) orderListView(title string, orders []models.Order, width int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
|
||||
// orderCardHeight is the rendered height of one order card:
|
||||
// border (2) + content lines (2), no vertical padding.
|
||||
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)
|
||||
|
||||
if !m.OrdersLoaded {
|
||||
return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
|
||||
return contentStyle.Render("Loading orders...")
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
|
||||
return contentStyle.Render("No orders yet")
|
||||
}
|
||||
if m.account.orderViewState == 2 {
|
||||
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
|
||||
}
|
||||
|
||||
boxWidth := width - 2
|
||||
// Whole-card windowing: render only fully-visible cards, never clipping a
|
||||
// card mid-border. Both scroll arrows render in BuildAccountView's gap
|
||||
// rows, so the cards span exactly the menu panel's rows.
|
||||
page := m.orderListPageSize()
|
||||
start := m.orderHiddenAbove()
|
||||
end := start + page
|
||||
if end > len(orders) {
|
||||
end = len(orders)
|
||||
}
|
||||
|
||||
// Build cards and join vertically — no manual "\n" gaps means fixed spacing
|
||||
var cards []string
|
||||
for i, order := range orders {
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
|
||||
cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
|
||||
rows = append(rows, m.buildOrderCard(orders[i], width, isSelected))
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// In list browsing mode (viewState 1), just show cards — no title
|
||||
// (the left panel menu already shows "order history" as selected)
|
||||
return cardList
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
// buildOrderCard renders a single order as a bordered box with fixed height.
|
||||
|
||||
@@ -9,36 +9,42 @@ import (
|
||||
)
|
||||
|
||||
func (m Model) updateCartViewport() Model {
|
||||
contentW, _, _, _ := m.shopLayout()
|
||||
headerH := lipgloss.Height(m.BuildHeader())
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
// 2 = blank gap rows around the cart list
|
||||
availH := m.heightContainer - headerH - footerH - breadH - 2
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
if !m.cart.viewportReady {
|
||||
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.cart.viewport = viewport.New(viewport.WithWidth(contentW), viewport.WithHeight(availH))
|
||||
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.cart.viewportReady = true
|
||||
} else {
|
||||
m.cart.viewport.SetWidth(m.widthContent)
|
||||
m.cart.viewport.SetWidth(contentW)
|
||||
m.cart.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) generateCartContent() string {
|
||||
contentW, _, _, _ := m.shopLayout()
|
||||
|
||||
if m.IsCartEmpty() {
|
||||
emptyStyle := m.theme.TextDim().
|
||||
Align(lipgloss.Center).
|
||||
Width(m.widthContent).
|
||||
Width(contentW).
|
||||
Padding(2, 0)
|
||||
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
|
||||
return render
|
||||
}
|
||||
|
||||
cartItems := ""
|
||||
boxWidth := m.widthContent - 4
|
||||
// Cart cards span the full chrome width so they line up with the
|
||||
// header and footer boxes.
|
||||
boxWidth := contentW
|
||||
itemSlice := m.GetCartItemsSlice()
|
||||
|
||||
boxPadding := 0
|
||||
@@ -94,7 +100,7 @@ func (m Model) generateCartContent() string {
|
||||
boxContent = m.theme.TextAccent().Render(boxContent)
|
||||
}
|
||||
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
|
||||
cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box)
|
||||
cartItems += box
|
||||
|
||||
if idx < len(itemSlice)-1 {
|
||||
cartItems += "\n"
|
||||
@@ -123,12 +129,9 @@ func (m Model) CartView() string {
|
||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
|
||||
}
|
||||
}
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(m.cart.viewport.View()),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.cart.viewport.View(),
|
||||
)
|
||||
// Blank rows separate the list from the chrome; no Place wrapper so the
|
||||
// cards stay left-aligned with the header and footer boxes.
|
||||
return "\n" + m.cart.viewport.View() + "\n"
|
||||
}
|
||||
|
||||
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
|
||||
3
pkg/tui/doc.go
Normal file
3
pkg/tui/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package tui is the terminal UI you see over SSH: the shop, the cart, your
|
||||
// account, and the checkout flow, all drawn with Bubbletea.
|
||||
package tui
|
||||
@@ -6,20 +6,26 @@ import (
|
||||
"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 {
|
||||
keybindStyle := m.theme.TextDim().Bold(true)
|
||||
descStyle := m.theme.TextDim()
|
||||
_, _, _, wideW := m.shopLayout()
|
||||
|
||||
item := func(k, v string) string {
|
||||
return pBody.Render(k) + " " + pDim.Render(v)
|
||||
}
|
||||
|
||||
var keys string
|
||||
if m.size == small {
|
||||
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
|
||||
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
|
||||
keys = item("m", "menu")
|
||||
} else {
|
||||
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(" · "))
|
||||
}
|
||||
|
||||
parts := []string{}
|
||||
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)
|
||||
row := lipgloss.NewStyle().Width(wideW).Align(lipgloss.Center).Render(keys)
|
||||
return panel(wideW, 0, row)
|
||||
}
|
||||
|
||||
@@ -2,57 +2,72 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
)
|
||||
|
||||
func (m Model) BuildHeader() string {
|
||||
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(" ")
|
||||
|
||||
mark := bold("t") + cursor
|
||||
logo := bold("terminal")
|
||||
menu := bold("m") + base(" ☰")
|
||||
|
||||
tab := func(key, name string, active bool) string {
|
||||
if active {
|
||||
return accent(key + " " + name)
|
||||
// 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
|
||||
}
|
||||
return accent(key) + base(" "+name)
|
||||
rSum += r * item.Quantity
|
||||
gSum += g * item.Quantity
|
||||
bSum += b * item.Quantity
|
||||
n += item.Quantity
|
||||
}
|
||||
shop := tab("s", "shop", m.currentPage == shopPage)
|
||||
account := tab("a", "account", m.currentPage == accountPage)
|
||||
|
||||
priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
|
||||
countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
|
||||
cartLabel := base(" cart")
|
||||
if m.inCartFlow() {
|
||||
cartLabel = accent(" cart")
|
||||
if n == 0 {
|
||||
return cWhite
|
||||
}
|
||||
cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
|
||||
|
||||
var tabs []string
|
||||
switch m.size {
|
||||
case small:
|
||||
tabs = []string{mark, cart}
|
||||
case medium:
|
||||
tabs = []string{menu, logo, cart}
|
||||
default:
|
||||
tabs = []string{logo, shop, account, cart}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
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 {
|
||||
_, _, _, wideW := m.shopLayout()
|
||||
|
||||
brand := pWht.Bold(true).Render("terminal")
|
||||
|
||||
pill := lipgloss.NewStyle().Background(cPill).Foreground(cWhite).Bold(true).Padding(0, 1)
|
||||
tab := func(key, label string, active bool) string {
|
||||
if active {
|
||||
return pill.Render(label)
|
||||
}
|
||||
return pDim.Render(key+" ") + pBody.Render(label)
|
||||
}
|
||||
|
||||
var left string
|
||||
if m.size == small {
|
||||
left = brand
|
||||
} 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() {
|
||||
cartLabel = pWht.Bold(true).Render("cart ")
|
||||
}
|
||||
cart := cartLabel +
|
||||
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)
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
row := left + strings.Repeat(" ", gap) + cart
|
||||
|
||||
return panel(wideW, 0, row)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ type Model struct {
|
||||
Fingerprint string
|
||||
AccessToken string
|
||||
AuthFingerprintKey string // shared secret for /auth/token refresh
|
||||
StripePublicKey string
|
||||
|
||||
// One-shop auth command - capture pubKeyStr in closure, not stored on Model
|
||||
authCmd tea.Cmd
|
||||
@@ -143,10 +142,7 @@ type Model struct {
|
||||
}
|
||||
|
||||
type shopState struct {
|
||||
selected int
|
||||
menuViewport viewport.Model
|
||||
detailViewport viewport.Model
|
||||
viewportsReady bool
|
||||
selected int
|
||||
}
|
||||
|
||||
type splashState struct {
|
||||
@@ -186,6 +182,7 @@ type accountState struct {
|
||||
cursor int
|
||||
orderViewState int
|
||||
orderCursor int
|
||||
orderWindowStart int // first fully-visible card in the windowed order list
|
||||
orderYOffset int // saved scroll pos when entering order detail
|
||||
faqFocused bool
|
||||
addressListFocused bool
|
||||
@@ -241,7 +238,6 @@ func (m Model) SwitchPage(p page) Model {
|
||||
|
||||
func (m Model) ShopSwitch() (Model, tea.Cmd) {
|
||||
m = m.SwitchPage(shopPage)
|
||||
m = m.updateShopViewports()
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "products"},
|
||||
{key: "+/-", value: "qty"},
|
||||
@@ -266,13 +262,12 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
} else {
|
||||
// Keep this list short enough to fit on one footer line.
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "items"},
|
||||
{key: "+/-", value: "qty"},
|
||||
{key: "p/enter", value: "checkout"},
|
||||
{key: "enter", value: "checkout"},
|
||||
{key: "s", value: "shop"},
|
||||
{key: "a", value: "account"},
|
||||
{key: "pgup/pgdn", value: "scroll"},
|
||||
{key: "?", value: "help"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
@@ -736,7 +731,6 @@ func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
|
||||
if m.APIClient == nil {
|
||||
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
|
||||
}
|
||||
stripe.Key = m.StripePublicKey
|
||||
tok, err := stripetoken.New(&stripe.TokenParams{
|
||||
Card: &stripe.CardParams{
|
||||
Name: stripe.String(form.CardName),
|
||||
@@ -843,6 +837,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Espresso",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 5,
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 350, // $3.50
|
||||
@@ -852,6 +847,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Latte",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 2,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica Blend",
|
||||
Price: 500, // $5.00
|
||||
@@ -861,6 +857,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Cappuccino",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 8,
|
||||
BeanType: "Italian Blend",
|
||||
Price: 450, // $4.50
|
||||
@@ -870,6 +867,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Americano",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 16,
|
||||
BeanType: "Arabica",
|
||||
Price: 400, // $4.00
|
||||
@@ -879,6 +877,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Mocha",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 16,
|
||||
BeanType: "Colombian",
|
||||
Price: 550, // $5.50
|
||||
@@ -888,6 +887,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Macchiato",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 3,
|
||||
BeanType: "Robusta Blend",
|
||||
Price: 425, // $4.25
|
||||
@@ -905,11 +905,12 @@ func NewModel(username string) Model {
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context.
|
||||
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
// The publishable Stripe key is wired into stripe.Key once at startup in
|
||||
// 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.Fingerprint = fingerprint
|
||||
m.AuthFingerprintKey = clientSecret
|
||||
m.StripePublicKey = stripePublicKey
|
||||
m.currentPage = splashPage
|
||||
if apiURL != "" {
|
||||
m.APIClient = api.NewClient(apiURL, "")
|
||||
@@ -931,10 +932,6 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
|
||||
return m
|
||||
}
|
||||
|
||||
func newViewport(w, h int) viewport.Model {
|
||||
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
|
||||
}
|
||||
|
||||
func (m Model) collectCardCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil {
|
||||
|
||||
70
pkg/tui/palette.go
Normal file
70
pkg/tui/palette.go
Normal file
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
}
|
||||
@@ -276,9 +276,7 @@ func (m Model) updatePaymentViewport() Model {
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
availH = max(availH, 1)
|
||||
if !m.payment.viewportReady {
|
||||
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
||||
@@ -303,7 +301,7 @@ func (m Model) PaymentPageView() string {
|
||||
m.widthContainer,
|
||||
m.payment.viewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.renderPaymentHttpsView(),
|
||||
m.renderPaymentHTTPSView(),
|
||||
)
|
||||
} else if m.payment.view == 0 && m.payment.form == nil {
|
||||
content = m.RenderCardList()
|
||||
@@ -355,11 +353,16 @@ func (m Model) RenderCardList() string {
|
||||
boxes = append(boxes, box)
|
||||
}
|
||||
|
||||
sshFocused := m.payment.cardCursor == len(m.SavedCards)
|
||||
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
|
||||
// SSH inline card-add is dormant until i have a norwegian org
|
||||
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is
|
||||
// discoverable, but render it dimmed and exclude it from cursor space
|
||||
// (handled in PaymentUpdate below) so it can not be picked
|
||||
dim := m.theme.TextDim().Render
|
||||
sshLabel := dim("+ add card via ssh") + " " + dim("(WIP)")
|
||||
sshBox := m.CreateBox(m.formatListItem(sshLabel, false), false)
|
||||
boxes = append(boxes, sshBox)
|
||||
|
||||
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
|
||||
httpsFocused := m.payment.cardCursor == len(m.SavedCards)
|
||||
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
|
||||
boxes = append(boxes, httpsBox)
|
||||
|
||||
@@ -369,7 +372,7 @@ func (m Model) RenderCardList() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
func (m Model) renderPaymentHttpsView() string {
|
||||
func (m Model) renderPaymentHTTPSView() string {
|
||||
baseStyle := m.theme.TextLabel()
|
||||
accentStyle := m.theme.TextHighlight()
|
||||
|
||||
@@ -501,7 +504,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.payment.cardCursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.payment.cardCursor < len(m.SavedCards)+1 {
|
||||
if m.payment.cardCursor < len(m.SavedCards) {
|
||||
m.payment.cardCursor++
|
||||
}
|
||||
|
||||
@@ -511,17 +514,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.SelectedCard = &selected
|
||||
return m.ReviewSwitch()
|
||||
}
|
||||
if m.payment.cardCursor == len(m.SavedCards) {
|
||||
m.payment.view = 1
|
||||
m.payment.form = m.InitPaymentForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, m.payment.form.form.Init()
|
||||
}
|
||||
// Browser payment
|
||||
// Browser flow, the only reachable add-card path while the SSH
|
||||
// inline form is dormant (see RenderCardList)
|
||||
m.payment.view = 2
|
||||
m.payment.collectURL = nil
|
||||
m.payment.collectTimeOut = false
|
||||
@@ -546,16 +540,6 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
|
||||
m.payment.cardCursor--
|
||||
}
|
||||
if len(m.SavedCards) == 0 {
|
||||
m.payment.view = 1
|
||||
m.payment.form = m.InitPaymentForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
|
||||
}
|
||||
return m, m.deleteCardCmd(card.ID)
|
||||
}
|
||||
}
|
||||
|
||||
91
pkg/tui/payment_form_test.go
Normal file
91
pkg/tui/payment_form_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// newPaymentCardListModel parks a Model on the payment page card-list view
|
||||
// with n synthetic saved cards. Baseline for assertint that the dormant
|
||||
// "+ add card via ssh" row is dimmed and unreachable by cursor navigation.
|
||||
func newPaymentCardListModel(t *testing.T, n int) Model {
|
||||
t.Helper()
|
||||
m := NewModel("test")
|
||||
m = m.updateLayout(120, 40)
|
||||
m.currentPage = paymentPage
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
m.payment.cardCursor = 0
|
||||
for i := range n {
|
||||
m.SavedCards = append(m.SavedCards, models.Card{
|
||||
ID: uint(i + 1),
|
||||
Brand: "visa",
|
||||
Last4: "4242",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func keyEnter() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
|
||||
|
||||
func TestRenderCardList_SshOptionComingSoon(t *testing.T) {
|
||||
m := newPaymentCardListModel(t, 1)
|
||||
out := m.RenderCardList()
|
||||
if !strings.Contains(out, "add card via ssh") {
|
||||
t.Fatalf("ssh option missing from card list output:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "WIP") {
|
||||
t.Fatalf("want 'WIP' marked on ssh option:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentUpdate_DownNavigationStopsAtBrowserSlot(t *testing.T) {
|
||||
m := newPaymentCardListModel(t, 2)
|
||||
for range 10 {
|
||||
var cmd tea.Cmd
|
||||
m, cmd = m.PaymentUpdate(keyJ())
|
||||
_ = cmd
|
||||
}
|
||||
want := len(m.SavedCards) // browser slot; ssh slot in unreachable
|
||||
if m.payment.cardCursor != want {
|
||||
t.Fatalf("cursor stopped at %d, want %d (browser slot)", m.payment.cardCursor, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentUpdate_EnterOnBrowserSlotLaunchesBrowserFlow(t *testing.T) {
|
||||
m := newPaymentCardListModel(t, 1)
|
||||
m.payment.cardCursor = len(m.SavedCards) // browser slot
|
||||
newM, cmd := m.PaymentUpdate(keyEnter())
|
||||
if newM.payment.view != 2 {
|
||||
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
|
||||
}
|
||||
if newM.payment.form != nil {
|
||||
t.Fatalf("payment.form must be nil after enter; ssh inline form is dormant")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected collectCardCmd, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentUpdate_EnterWithZeroCardsLaunchesBrowserFlow(t *testing.T) {
|
||||
m := newPaymentCardListModel(t, 0)
|
||||
if m.payment.cardCursor != 0 {
|
||||
t.Fatalf("default cursor = %d, want 0 ", m.payment.cardCursor)
|
||||
}
|
||||
newM, cmd := m.PaymentUpdate(keyEnter())
|
||||
if newM.payment.view != 2 {
|
||||
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
|
||||
}
|
||||
if newM.payment.form != nil {
|
||||
t.Fatalf("payment.form must be nil; ssh inline form is dormant")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected collectCardCmd, got nil")
|
||||
}
|
||||
}
|
||||
3
pkg/tui/qrfefe/doc.go
Normal file
3
pkg/tui/qrfefe/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package qrfefe turns text into a QR code drawn with terminal block
|
||||
// characters, handy for showing links you can scan right off the screen.
|
||||
package qrfefe
|
||||
194
pkg/tui/render_smoke_test.go
Normal file
194
pkg/tui/render_smoke_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
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(), ""))
|
||||
}
|
||||
}
|
||||
251
pkg/tui/shop.go
251
pkg/tui/shop.go
@@ -4,163 +4,173 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func (m Model) calculateShopWidths() (int, int) {
|
||||
menuWidth := 14
|
||||
if len(m.Coffees) == 0 {
|
||||
if m.size < large {
|
||||
return m.widthContent, m.widthContent
|
||||
}
|
||||
return menuWidth, m.widthContent - menuWidth
|
||||
}
|
||||
for _, c := range m.Coffees {
|
||||
if w := lipgloss.Width(c.Name); w > menuWidth {
|
||||
menuWidth = w
|
||||
}
|
||||
}
|
||||
if menuWidth > 0 {
|
||||
menuWidth += 4
|
||||
}
|
||||
const (
|
||||
// shopBodyInnerHeight fixes the inner height of the shop body panels so
|
||||
// the detail box never resizes with the description.
|
||||
shopBodyInnerHeight = 13
|
||||
// shopDescLines pins the description region to an exact line count.
|
||||
shopDescLines = 3
|
||||
)
|
||||
|
||||
// shopLayout returns the shared chrome widths, all derived from one content
|
||||
// width so the header, body panels, and footer line up exactly.
|
||||
// Math ported from the design prototype at ~/programming/test/shop.go:
|
||||
// menu(menuW+4) + gap(1) + detail(detailW+4) = contentW = wideW + 4.
|
||||
func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) {
|
||||
contentW = m.widthContent
|
||||
contentW = min(contentW, 74)
|
||||
wideW = contentW - 4
|
||||
if m.size < large {
|
||||
menuWidth = m.widthContent
|
||||
// Stacked layout: both panels span the full content width.
|
||||
return contentW, wideW, wideW, wideW
|
||||
}
|
||||
detailWidth := m.widthContent - menuWidth
|
||||
if m.size < large {
|
||||
detailWidth = m.widthContent
|
||||
}
|
||||
return menuWidth, detailWidth
|
||||
menuW = 16
|
||||
detailW = contentW - 25
|
||||
return contentW, menuW, detailW, wideW
|
||||
}
|
||||
|
||||
func (m Model) updateShopViewports() Model {
|
||||
headerHeight := lipgloss.Height(m.BuildHeader())
|
||||
footerHeight := lipgloss.Height(m.BuildFooter())
|
||||
availableHeight := m.heightContainer - headerHeight - footerHeight
|
||||
|
||||
if len(m.Coffees) == 0 {
|
||||
return m
|
||||
// roastLevel maps a roast type to a 1-5 intensity for the roast meter.
|
||||
// It is the fallback for products stored before the roast_level column existed.
|
||||
func roastLevel(roastType string) int {
|
||||
rt := strings.ToLower(roastType)
|
||||
switch {
|
||||
case strings.Contains(rt, "dark"):
|
||||
return 5
|
||||
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
|
||||
}
|
||||
|
||||
func (m Model) getShopMenuContent() string {
|
||||
menuWidth, _ := m.calculateShopWidths()
|
||||
|
||||
selectedColor := m.theme.Highlight()
|
||||
if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
|
||||
if c := m.Coffees[m.shop.selected].Color; c != "" {
|
||||
selectedColor = lipgloss.Color(c)
|
||||
}
|
||||
// clampLines pads or truncates rendered text to exactly n lines so regions
|
||||
// like the description never change the panel height.
|
||||
func clampLines(s string, n int) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[:n]
|
||||
}
|
||||
|
||||
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)
|
||||
for len(lines) < n {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// roastMeter renders the strength dots, e.g. ● ● ● ○ ○
|
||||
func roastMeter(n int) string {
|
||||
n = max(0, min(n, 5))
|
||||
on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n))
|
||||
off := pDim.Render(strings.Repeat("○ ", 5-n))
|
||||
return strings.TrimRight(on+off, " ")
|
||||
}
|
||||
|
||||
func (m Model) shopMenuContent(w int) string {
|
||||
var b strings.Builder
|
||||
for i, c := range m.Coffees {
|
||||
if i == m.shop.selected {
|
||||
b.WriteString(highlighted.Render(c.Name) + "\n")
|
||||
b.WriteString(lipgloss.NewStyle().Width(w).
|
||||
Background(lipgloss.Color(c.Color)).
|
||||
Foreground(contrastColor(c.Color)).
|
||||
Bold(true).
|
||||
Render(" · " + c.Name))
|
||||
} else {
|
||||
b.WriteString(normal.Render(c.Name) + "\n")
|
||||
dot := lipgloss.NewStyle().Foreground(lipgloss.Color(c.Color)).Render("·")
|
||||
b.WriteString(lipgloss.NewStyle().Width(w).
|
||||
Render(" " + dot + " " + pBody.Render(c.Name)))
|
||||
}
|
||||
if i < len(m.Coffees)-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) getShopDetailContent() string {
|
||||
if len(m.Coffees) == 0 {
|
||||
return ""
|
||||
}
|
||||
func (m Model) shopDetailContent(w int) string {
|
||||
coffee := m.Coffees[m.shop.selected]
|
||||
_, detailWidth := m.calculateShopWidths()
|
||||
if m.size >= large {
|
||||
detailWidth -= 2
|
||||
}
|
||||
|
||||
quantity := 0
|
||||
if item, exists := m.Cart[coffee.ID]; exists {
|
||||
quantity = item.Quantity
|
||||
}
|
||||
|
||||
detail := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.theme.TextAccent().Bold(true).Render(coffee.Name),
|
||||
m.theme.TextBody().Render(
|
||||
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
|
||||
),
|
||||
name := pWht.Bold(true).Render(coffee.Name)
|
||||
|
||||
sep := pDim.Render(" · ")
|
||||
spec := pBody.Render(strings.ToUpper(coffee.RoastType)) + sep +
|
||||
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,
|
||||
"",
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
|
||||
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
|
||||
),
|
||||
price+" "+stock,
|
||||
"",
|
||||
m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
|
||||
desc,
|
||||
"",
|
||||
fmt.Sprintf("- %d +", quantity),
|
||||
stepper,
|
||||
)
|
||||
return lipgloss.NewStyle().Width(detailWidth).Render(detail)
|
||||
}
|
||||
|
||||
func (m Model) ShopView() string {
|
||||
if !m.shop.viewportsReady {
|
||||
m = m.updateShopViewports()
|
||||
}
|
||||
if len(m.Coffees) == 0 {
|
||||
return lipgloss.Place(m.widthContent, m.heightContainer,
|
||||
lipgloss.Center, lipgloss.Center, "No products available.")
|
||||
}
|
||||
|
||||
m.shop.menuViewport.SetContent(m.getShopMenuContent())
|
||||
m.shop.detailViewport.SetContent(m.getShopDetailContent())
|
||||
_, menuW, detailW, _ := m.shopLayout()
|
||||
|
||||
mc := m.shopMenuContent(menuW)
|
||||
dc := m.shopDetailContent(detailW)
|
||||
|
||||
var body string
|
||||
if m.size < large {
|
||||
return lipgloss.JoinVertical(lipgloss.Top,
|
||||
m.shop.menuViewport.View(),
|
||||
m.shop.detailViewport.View(),
|
||||
body = lipgloss.JoinVertical(lipgloss.Left,
|
||||
panel(menuW, 0, mc),
|
||||
panel(detailW, 0, dc),
|
||||
)
|
||||
} 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,
|
||||
m.shop.menuViewport.View(),
|
||||
" ",
|
||||
m.shop.detailViewport.View(),
|
||||
)
|
||||
|
||||
// Blank rows separate the body from the header and footer boxes.
|
||||
return "\n" + body + "\n"
|
||||
}
|
||||
|
||||
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
var cmds []tea.Cmd
|
||||
|
||||
if len(m.Coffees) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
@@ -197,16 +207,7 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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...)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) shopMoveSelected(previous bool) Model {
|
||||
@@ -216,18 +217,8 @@ func (m Model) shopMoveSelected(previous bool) Model {
|
||||
} else {
|
||||
next++
|
||||
}
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
if next >= len(m.Coffees) {
|
||||
next = len(m.Coffees) - 1
|
||||
}
|
||||
next = max(next, 0)
|
||||
next = min(next, len(m.Coffees)-1)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
|
||||
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
|
||||
padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
|
||||
base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
|
||||
base := m.theme.Base().Border(lipgloss.RoundedBorder()).Width(totalWidth)
|
||||
|
||||
var style lipgloss.Style
|
||||
if selected {
|
||||
style = base.BorderForeground(m.theme.Highlight())
|
||||
style = base.BorderForeground(cWhite)
|
||||
} else {
|
||||
style = base.BorderForeground(m.theme.Border())
|
||||
style = base.BorderForeground(cLine)
|
||||
}
|
||||
return style.Padding(paddingV, paddingH).Render(padded)
|
||||
}
|
||||
|
||||
3
pkg/tui/theme/doc.go
Normal file
3
pkg/tui/theme/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package theme keeps the colors and styles in one spot so the whole UI
|
||||
// looks like it was built by the same person.
|
||||
package theme
|
||||
@@ -16,8 +16,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if tick.seq == m.resizeSeq {
|
||||
m = m.updateLayout(m.pendingWidth, m.pendingHeight)
|
||||
switch m.currentPage {
|
||||
case shopPage:
|
||||
m = m.updateShopViewports()
|
||||
case cartPage:
|
||||
m = m.updateCartViewport()
|
||||
case shippingPage:
|
||||
|
||||
@@ -62,7 +62,8 @@ func (m Model) renderString() string {
|
||||
if breadcrumbs != "" {
|
||||
breadcrumbsHeight = lipgloss.Height(breadcrumbs)
|
||||
}
|
||||
footerHeight := 1
|
||||
footer := m.BuildFooter()
|
||||
footerHeight := lipgloss.Height(footer)
|
||||
marginTop := 1
|
||||
marginBottom := 1
|
||||
bufferSpace := 1
|
||||
@@ -87,17 +88,15 @@ func (m Model) renderString() string {
|
||||
errorStyle = errorStyle.Width(m.widthContent - 4)
|
||||
}
|
||||
errorBanner := errorStyle.Render(m.error.message)
|
||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
content = errorBanner + "\n" + m.buildPageContent()
|
||||
} else if m.notice != nil {
|
||||
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
|
||||
noticeBanner := noticeStyle.Render(m.notice.message)
|
||||
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
content = noticeBanner + "\n" + m.buildPageContent()
|
||||
} else {
|
||||
content = m.buildPageContent(availableContentHeight)
|
||||
content = m.buildPageContent()
|
||||
}
|
||||
|
||||
footer := m.BuildFooter()
|
||||
|
||||
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
|
||||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
|
||||
m.currentPage == confirmPage || m.currentPage == accountPage
|
||||
@@ -137,10 +136,10 @@ func (m Model) renderString() string {
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) buildPageContent(height int) string {
|
||||
func (m Model) buildPageContent() string {
|
||||
switch m.currentPage {
|
||||
case accountPage:
|
||||
return m.BuildAccountView(height)
|
||||
return m.BuildAccountView()
|
||||
case shippingPage:
|
||||
return m.ShippingPageView()
|
||||
case paymentPage:
|
||||
|
||||
3
pkg/utils/doc.go
Normal file
3
pkg/utils/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package utils is the catch-all drawer for tiny HTTP helpers that didn't
|
||||
// earn a package of their own.
|
||||
package utils
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
// SuccessResponse represents a successful API response
|
||||
type SuccessResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error API response
|
||||
@@ -20,13 +20,13 @@ type ErrorResponse struct {
|
||||
|
||||
// Error contains error details
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// RespondSuccess sends a successful JSON response
|
||||
func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
@@ -39,7 +39,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
}
|
||||
|
||||
// RespondError sends an error JSON response
|
||||
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) {
|
||||
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
|
||||
3
pkg/validate/doc.go
Normal file
3
pkg/validate/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package validate gives user input a once-over before the rest of the app
|
||||
// trusts it.
|
||||
package validate
|
||||
Reference in New Issue
Block a user