Compare commits

..

2 Commits

Author SHA1 Message Date
bad8b3abff style(tui): gofmt main.go and menu.go 2026-05-25 19:05:03 +02:00
6598f63cb2 refactor(tui): port to charm v2
bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly
mechanical chur, new import paths, vewport getter/setter api, view()
returns tea.view now, huh.theme became an interface so the theme package
needed a small reshape. Render plumbing is gone (no more lipgloss.Render
field on the Model or MakeRenderer in main.go). All pages still work
over ssh. setting up for the refund composer next
2026-05-25 19:00:08 +02:00
26 changed files with 85 additions and 1363 deletions

View File

@@ -8,9 +8,9 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: actions/setup-go@v6
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
@@ -30,7 +30,6 @@ jobs:
fi
- name: Lint
if: ${{ !env.ACT }}
uses: golangci/golangci-lint-action@v7
with:
version: v2.4.0

View File

@@ -8,8 +8,8 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run tests

3
.gitignore vendored
View File

@@ -5,9 +5,6 @@
.env.example
.env.prod
docs/sca-psd2-compliance.md
accept-a-payment/
FULFILLMENT.md
.tmp-gocache/

View File

@@ -7,7 +7,7 @@ import (
"fmt"
"net/http"
"strconv"
"time"
"sync"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
@@ -24,43 +24,35 @@ import (
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
)
const RedirectTTL = 10 * time.Minute
// payRedirects maps short tokens to full Stripe checkout URLs.
var (
payRedirects = map[string]string{}
payRedirectsMu sync.Mutex
)
func storeRedirect(stripeURL, purpose string) (string, error) {
func storeRedirect(stripeURL string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
now := time.Now()
rec := models.PayRedirect{
Token: token,
URL: stripeURL,
Purpose: purpose,
CreatedAt: now,
ExpiresAt: now.Add(RedirectTTL),
}
if err := database.GetDB().Create(&rec).Error; err != nil {
return "", err
}
payRedirectsMu.Lock()
payRedirects[token] = stripeURL
payRedirectsMu.Unlock()
return token, nil
}
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
func PayRedirect(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
db := database.GetDB().WithContext(r.Context())
var rec models.PayRedirect
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
payRedirectsMu.Lock()
url, ok := payRedirects[token]
payRedirectsMu.Unlock()
if !ok {
http.NotFound(w, r)
return
}
if time.Now().After(rec.ExpiresAt) {
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
http.NotFound(w, r)
return
}
http.Redirect(w, r, rec.URL, http.StatusFound)
http.Redirect(w, r, url, http.StatusFound)
}
// CardHandler handles saved payment method CRUD.
@@ -176,7 +168,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return
}
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
token, err := storeRedirect(sess.URL)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return

View File

@@ -24,12 +24,11 @@ import (
type CartHandler struct {
stripeKey string
appURL string
}
// NewCartHandler creates a new cart handler with the Stripe secret key
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
func NewCartHandler(stripeSecretKey string) *CartHandler {
return &CartHandler{stripeKey: stripeSecretKey}
}
// getOrCreateCart finds the users cart or creates one.
@@ -392,15 +391,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
// Charge the card. The idempotency key is tied to the order ID so retries
// never produce a double charge.
piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(true),
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
Enabled: stripe.Bool(true),
AllowRedirects: stripe.String("never"),
},
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Shipping: &stripe.ShippingDetailsParams{
Name: stripe.String(address.Name),
Address: &stripe.AddressParams{
@@ -423,17 +423,13 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
stripeDur := time.Since(stripeStart).Seconds()
if err != nil {
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
db.Model(&order).Update("status", models.OrderStatusFailed)
if stripeErr, ok := err.(*stripe.Error); ok {
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
return
}
db.Model(&order).Update("status", models.OrderStatusFailed)
switch stripeErr.Type {
case stripe.ErrorTypeCard:
middleware.RecordCartConversion("card_declined")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
case stripe.ErrorTypeInvalidRequest:
middleware.RecordCartConversion("stripe_invalid_request")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
@@ -445,7 +441,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
return
}
db.Model(&order).Update("status", models.OrderStatusFailed)
middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, err.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
@@ -453,11 +448,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
h.respondRequiresAction(w, &order, paymentMethodID, pi)
return
}
// Commit payment ID and clear cart in a single transaction.
txErr := db.Transaction(func(tx *gorm.DB) error {
order.StripePaymentID = pi.ID
@@ -499,95 +489,3 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
"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
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
db := database.GetDB()
// After an off-session authentication_required decline, the PI sits in
// status=requires_payment_method with payment_method detached. Re-confirm
// on-session with the same PM so Stripe pushes the PI into requires_action
// and populates next_action.redirect_to_url with the 3DS challenge URL.
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
if cerr == nil && confirmed != nil {
pi = confirmed
}
}
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
middleware.RecordCartConversion("sca_missing_next_action")
db.Model(order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
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.
if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID,
"status": models.OrderStatusPending,
}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return
}
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
return
}
middleware.RecordCartConversion("requires_action")
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"payment_intent_id": pi.ID,
"status": "requires_action",
"redirect_token": token,
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})
}
func mapStripeCardErrCode(code stripe.ErrorCode) string {
switch code {
case stripe.ErrorCodeAuthenticationRequired:
return "AUTHENTICATION_REQUIRED"
case "authentication_not_supported":
return "AUTHENTICATION_NOT_SUPPORTED"
case "card_declined", "":
return "CARD_DECLINED"
case "expired_card":
return "CARD_EXPIRED"
case "incorrect_cvc":
return "CARD_CVC_FAILED"
case "insufficient_funds":
return "CARD_INSUFFICIENT_FUNDS"
case "processing_error":
return "CARD_PROCESSING_ERROR"
}
return "CARD_DECLINED"
}
//nolint:unused // wired into Convert cart later
func appURLForHandler(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := r.Host
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
host = fh
}
return fmt.Sprintf("%s://%s", scheme, host)
}

View File

@@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder()
@@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
// Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]interface{}{
@@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
// Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
@@ -205,7 +205,7 @@ func TestSetItemRemove(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
// Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
@@ -244,7 +244,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -261,7 +261,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -278,7 +278,7 @@ func TestClearCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
// Add two items
for _, coffeeID := range []uint{1, 2} {
@@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) {
}
db.Create(&addr)
handler := NewCartHandler("", "")
handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) {
}
db.Create(&addr)
handler := NewCartHandler("", "")
handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) {
}
db.Create(&card)
handler := NewCartHandler("", "")
handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) {
}
db.Create(&card)
handler := NewCartHandler("", "")
handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -485,7 +485,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
// Add item but no address or card
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
@@ -508,7 +508,7 @@ func TestConvertCartMissingCard(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
db := database.GetDB()
// Add item and address
@@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
// Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -556,7 +556,7 @@ func TestMultipleItems(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("")
// Add three different items
for i := uint(1); i <= 3; i++ {
@@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) {
}
db.Create(&user2)
handler := NewCartHandler("", "")
handler := NewCartHandler("")
r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart)

View File

@@ -2,12 +2,9 @@ package handlers
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"
"terminalShop/pkg/bring"
@@ -34,16 +31,6 @@ type OrderHandler struct {
bringCustomerNumber string
}
type refundRequestPayload struct {
Reason string `json:"reason"`
Message string `json:"message"`
}
// refundRequestCooldown is the minimum interval between customer-initiated
// refund requests for the same order. Prevents accidental double-clicks and
// repeated submissions from spamming the operator's Slack channel.
const refundRequestCooldown = 15 * time.Minute
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
return &OrderHandler{
stripeKey: stripeSecretKey,
@@ -53,83 +40,6 @@ func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNum
}
}
// CreateRefundRequest posts a customer refund request into the order's Slack
// thread. It does not issue a Stripe refund.
func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
var req refundRequestPayload
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
req.Reason = strings.TrimSpace(req.Reason)
req.Message = strings.TrimSpace(req.Message)
if !models.IsValidRefundReason(req.Reason) {
utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil)
return
}
if req.Reason == models.RefundRequestReasonOther && req.Message == "" {
utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil)
return
}
var order models.Order
if err := db.Preload("User").Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
if !order.CanRequestRefund() {
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
"only paid, shipped, or delivered orders can request refunds", nil)
return
}
if order.LastRefundRequestAt != nil {
elapsed := time.Since(*order.LastRefundRequestAt)
if elapsed < refundRequestCooldown {
retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED",
fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1),
nil)
return
}
}
text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f",
req.Reason, order.User.Name, order.User.Email, order.TotalInDollars())
if req.Message != "" {
quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ")
text += "\n\n" + quoted
}
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
return
}
// Record the timestamp after a successful Slack post so a Slack outage
// doesn't consume the user's cooldown — they can retry immediately.
if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil {
slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err)
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"status": "sent",
})
}
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
@@ -140,34 +50,11 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"orders": orders,
})
}
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
var order models.Order
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"id": order.ID,
"status": string(order.Status),
})
}
// RefundOrder issues a full refund for an order via Stripe and marks it as
// refunded. Only orders in paid or shipped status can be refunded.
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,170 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
func setupOrderTestDB(t *testing.T) models.User {
t.Helper()
testDB := filepath.Join(t.TempDir(), "orders.db")
database.ResetForTesting()
t.Cleanup(database.ResetForTesting)
db, err := database.Connect(testDB)
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
if err := database.Seed(db); err != nil {
t.Fatalf("failed to seed: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:ordertestfingerprint",
SSHPublicKey: "ssh-ed25519 AAAA ordertestkey",
Name: "Order Test User",
Email: "orders@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
return user
}
func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request {
req := authRequest(method, url, body, userID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", orderID)
return req.WithContext(contextWithRoute(req.Context(), routeCtx))
}
func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context {
return context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
}
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
user := setupOrderTestDB(t)
handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Other","message":" "}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
w := httptest.NewRecorder()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "MESSAGE_REQUIRED" {
t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code)
}
}
func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
order := models.Order{
UserID: user.ID,
CardID: 1,
Status: models.OrderStatusFailed,
Subtotal: 100,
ShippingCost: 0,
Total: 100,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("failed to create order: %v", err)
}
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()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "INVALID_STATE" {
t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code)
}
}
func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
recent := time.Now().Add(-1 * time.Minute)
order := models.Order{
UserID: user.ID,
CardID: 1,
Status: models.OrderStatusPaid,
Subtotal: 100,
ShippingCost: 0,
Total: 100,
LastRefundRequestAt: &recent,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("failed to create order: %v", err)
}
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()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected status 429, got %d", w.Code)
}
if got := w.Header().Get("Retry-After"); got == "" {
t.Errorf("expected Retry-After header, got empty")
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "RATE_LIMITED" {
t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code)
}
}

View File

@@ -130,7 +130,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
resp, err := h.httpClient.Do(req)
if err != nil {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
return
}
defer func() { _ = resp.Body.Close() }()
@@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
_ = json.Unmarshal(bodyBytes, &result)
if resp.StatusCode >= 300 {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
return
}
@@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
return
}
if order.TrackingNumber == "" {
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
return
}
@@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
resp, err := h.httpClient.Do(req)
if err != nil {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
return
}

View File

@@ -167,16 +167,6 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
return
}
// authentication_required is recoverable — the cart handler re-confirms the
// PI on-session and the customer completes 3DS via redirect. Marking the
// order failed here would race with the recovery path and surface a
// momentarily-broken state in the TUI.
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
"order_id", orderIDStr, "pi", pi.ID)
return
}
db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed).Error; err != nil {
@@ -383,9 +373,7 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
if previousTrackingStatus != newStatus {
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
go func() {
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
}()
go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
}
if deliveredTransition {
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)

View File

@@ -46,7 +46,7 @@ func SetupRoutes(
healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL)
cartHandler := handlers.NewCartHandler(stripeSecretKey)
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
@@ -61,10 +61,6 @@ func SetupRoutes(
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
})
r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
})
r.Handle("/metrics", middleware.MetricsHandler())
r.Route("/api/v1", func(r chi.Router) {
@@ -130,8 +126,6 @@ func SetupRoutes(
// Orders
r.Get("/orders", orderHandler.GetOrders)
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
// Addresses

View File

@@ -56,6 +56,7 @@ type TokenResponse struct {
}
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
reqBody := TokenRequest{
@@ -280,31 +281,14 @@ type CardResponse struct {
}
// ConvertCartResponse represents the cart conversion (checkout) API response.
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
// redirect fields and OrderID instead.
type ConvertCartResponse struct {
Success bool `json:"success"`
Data struct {
Order *models.Order `json:"order,omitempty"`
OrderID uint `json:"order_id,omitempty"`
PaymentIntentID string `json:"payment_intent_id,omitempty"`
Status string `json:"status,omitempty"`
RedirectToken string `json:"redirect_token,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
Order models.Order `json:"order"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
// means the customer needs to complete 3DS at RedirectURL; poll
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
type CheckoutOutcome struct {
Order *models.Order
OrderID uint
RequiresAction bool
RedirectURL string
}
// GetCart fetches the user's current cart.
func (c *Client) GetCart() (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
@@ -484,9 +468,7 @@ func (c *Client) ClearCart() error {
}
// ConvertCart converts the cart into an order, charging the saved card.
// Returns a CheckoutOutcome that either carries the completed Order or
// indicates the customer must complete 3DS authentication via RedirectURL.
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
func (c *Client) ConvertCart() (*models.Order, error) {
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
req, err := http.NewRequest("POST", url, nil)
@@ -512,55 +494,7 @@ func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
return nil, fmt.Errorf("failed to convert cart")
}
if convertResp.Data.Status == "requires_action" {
return &CheckoutOutcome{
OrderID: convertResp.Data.OrderID,
RequiresAction: true,
RedirectURL: convertResp.Data.RedirectURL,
}, nil
}
if convertResp.Data.Order == nil {
return nil, fmt.Errorf("malformed convert response: missing order")
}
return &CheckoutOutcome{
Order: convertResp.Data.Order,
OrderID: convertResp.Data.Order.ID,
}, nil
}
type OrderStatusResponse struct {
Success bool `json:"success"`
Data struct {
ID uint `json:"id"`
Status string `json:"status"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) GetOrderStatus(id uint) (string, error) {
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return "", fmt.Errorf("failed to fetch order status: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var sResp OrderStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
return "", fmt.Errorf("failed to decode order status response: %w", err)
}
if !sResp.Success {
if sResp.Error != nil {
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
}
return "", fmt.Errorf("failed to fetch order status")
}
return sResp.Data.Status, nil
return &convertResp.Data.Order, nil
}
// GetCards fetches all saved cards for the user.
@@ -702,20 +636,6 @@ type OrderResponse struct {
Error *APIError `json:"error,omitempty"`
}
// RefundRequest is the customer-facing refund request payload.
type RefundRequest struct {
Reason string `json:"reason"`
Message string `json:"message"`
}
type refundRequestResponse struct {
Success bool `json:"success"`
Data struct {
Status string `json:"status"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) GetOrders() ([]models.Order, error) {
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
@@ -744,42 +664,6 @@ func (c *Client) GetOrders() ([]models.Order, error) {
return ordersResp.Data.Orders, nil
}
// CreateRefundRequest posts a refund request for an order.
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
jsonData, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("failed to marshal refund request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create refund request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to send refund request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var refundResp refundRequestResponse
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
return fmt.Errorf("failed to decode refund response: %w", err)
}
if !refundResp.Success {
if refundResp.Error != nil {
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
}
return fmt.Errorf("failed to send refund request")
}
return nil
}
// The Addresses API response
type AddressResponse struct {
Success bool `json:"success"`

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"log/slog"
"terminalShop/pkg/models"
"gorm.io/gorm"
@@ -22,7 +21,6 @@ const (
eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = "order_requires_action"
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased"
@@ -300,15 +298,3 @@ func attrsToMap(a []any) map[string]any {
}
return m
}
//nolint:unused // called from ConvertCart in the next update
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrderRequiresAction,
UserID: userID,
OrderID: orderID,
StripeID: paymentIntentID,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRequiresAction, e)
}

View File

@@ -3,7 +3,6 @@ package database
import (
"fmt"
"log"
"terminalShop/pkg/models"
"gorm.io/gorm"
@@ -25,7 +24,6 @@ func Migrate(db *gorm.DB) error {
&models.OrderItem{}, // Line items within orders
&models.Address{}, // Saved addresses
&models.OrderEvent{},
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
); err != nil {
return fmt.Errorf("migration failed: %w", err)
}

View File

@@ -66,10 +66,6 @@ type Order struct {
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
SlackThreadTS *string `gorm:"size:64" json:"-"`
// LastRefundRequestAt timestamps the most recent customer-initiated refund
// request for this order. Used to rate-limit repeat submissions.
LastRefundRequestAt *time.Time `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
@@ -117,35 +113,3 @@ func (o *Order) TotalInDollars() float64 {
func (o *Order) IsActive() bool {
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
}
// RefundRequestReasons is the canonical list of customer-facing refund reasons
// accepted by both the API and the TUI.
var RefundRequestReasons = []string{
"Wrong item received",
"Item damaged in transit",
"Item never arrived",
"Quality issue",
"Other",
}
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
const RefundRequestReasonOther = "Other"
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
func IsValidRefundReason(r string) bool {
for _, reason := range RefundRequestReasons {
if reason == r {
return true
}
}
return false
}
// CanRequestRefund reports whether a customer is allowed to request a refund
// for this order. Paid, shipped, and delivered orders qualify; everything else
// is either still unpaid or already in a terminal state.
func (o *Order) CanRequestRefund() bool {
return o.Status == OrderStatusPaid ||
o.Status == OrderStatusShipped ||
o.Status == OrderStatusDelivered
}

View File

@@ -11,27 +11,10 @@ const (
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
DisplayKindSuccess DisplayKind = "success" // delivered
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue
)
// refundPending reports whether the order has an open customer refund request
// that has not yet been resolved by a terminal status change.
func (order *Order) refundPending() bool {
if order.LastRefundRequestAt == nil {
return false
}
switch order.Status {
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
return false
}
return true
}
func (order *Order) DisplayState() string {
if order.refundPending() {
return "Refund Requested"
}
switch order.Status {
case OrderStatusPending:
return "Awaiting payment"
@@ -71,9 +54,6 @@ func (order *Order) DisplayState() string {
}
func (order *Order) DisplayKind() DisplayKind {
if order.refundPending() {
return DisplayKindRefund
}
switch order.Status {
case OrderStatusPending:
return DisplayKindNeutral

View File

@@ -1,12 +1,8 @@
package models
import (
"testing"
"time"
)
import "testing"
func TestOrderDisplayState(t *testing.T) {
now := time.Now()
tests := []struct {
name string
order Order
@@ -27,10 +23,6 @@ func TestOrderDisplayState(t *testing.T) {
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -1,23 +0,0 @@
package models
import "time"
// PayRedirect maps a short token to a stripe hosted url with a TTL.
// survives api restarts so a customer mid-3Ds authentication still
// lands on the bank-hosted challenge page after a deploy or crash
type PayRedirect struct {
Token string `gorm:"primaryKey;size:64" json:"token"`
URL string `gorm:"type:text;not null" json:"url"`
Purpose string `gorm:"size:32;not null" json:"purpose"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
}
const (
RedirectPurposeCheckout3DS = "checkout_3ds"
RedirectPurposeAddCard = "add_card"
)
func (PayRedirect) TableName() string {
return "pay_redirects"
}

View File

@@ -188,49 +188,29 @@ func SlackUnpinOrder(orderID uint) {
}
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
// It looks up the thread ts from the database and returns an error when the
// reply cannot be posted.
func SlackPostToOrderThread(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, false)
}
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
// to the channel (reply_broadcast=true) so channel members get a normal
// notification. Use for messages that need immediate operator attention.
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, true)
}
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
// Looks up the thread ts from the database. Silent no-op if the order has no
// saved thread (Slack was disabled when it was created, or post failed earlier).
func SlackPostToOrderThread(orderID uint, text string) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return fmt.Errorf("slack is not configured")
return
}
var order models.Order
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
return fmt.Errorf("failed to look up order thread: %w", err)
return
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return fmt.Errorf("order has no slack thread")
return
}
payload := map[string]any{
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": text,
}
if broadcast {
payload["reply_broadcast"] = true
}
if _, ok := postSlackMessage(token, payload); !ok {
slog.Warn("failed to post slack thread reply", "order_id", orderID)
return fmt.Errorf("failed to post slack thread reply")
}
return nil
})
}
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {

View File

@@ -288,13 +288,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
return m, cmd
case "r":
if m.account.orderViewState == 2 {
if order, ok := m.selectedRefundableOrder(); ok {
return m.OpenRefundComposer(order.ID)
}
}
case "x", "d":
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
m.account.addressDeleting = &m.account.addressCursor
@@ -346,15 +339,11 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.account.orderYOffset = m.account.detailViewport.YOffset()
m.account.orderViewState = 2
m.account.detailViewport.GotoTop()
footer := []footerCommand{
m.footer = []footerCommand{
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "q", value: "quit"},
}
if _, ok := m.selectedRefundableOrder(); ok {
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
}
m.footer = footer
}
}
case "addresses":

View File

@@ -37,21 +37,6 @@ func (m Model) HistoryOrders() []models.Order {
return out
}
// selectedRefundableOrder returns the order under the cursor in the active
// order-detail view, paired with ok=true, when the user is allowed to file a
// refund request for it. Returns (zero, false) otherwise.
func (m Model) selectedRefundableOrder() (models.Order, bool) {
orders := m.currentOrderList()
if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) {
return models.Order{}, false
}
order := orders[m.account.orderCursor]
if !order.CanRequestRefund() {
return models.Order{}, false
}
return order, true
}
func (m Model) currentOrderList() []models.Order {
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return nil
@@ -256,11 +241,7 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
b.WriteString("\n\n")
// Footer hint
if order.CanRequestRefund() {
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
} else {
b.WriteString(dimStyle.Render("esc: back to orders"))
}
b.WriteString(dimStyle.Render("esc: back to orders"))
return b.String()
}
@@ -278,9 +259,10 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
case models.DisplayKindError:
return m.theme.TextError()
case models.DisplayKindWarning:
// !TODO add refunds and change this
// No warning color in theme yet, reuse loading pink as visual flag
// for now
return m.theme.TextLoading()
case models.DisplayKindRefund:
return m.theme.TextBrand()
}
return m.theme.TextHighlight()
}

View File

@@ -3,9 +3,7 @@ package tui
import (
"fmt"
"strings"
"terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
@@ -170,18 +168,6 @@ func (m Model) ReviewView() string {
if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
}
if m.review.authFailed {
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again")
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
if m.review.requiresAction {
return m.renderRequiresActionView()
}
if m.review.success {
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
return lipgloss.Place(
@@ -200,52 +186,18 @@ func (m Model) ReviewView() string {
)
}
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
func (m Model) renderRequiresActionView() string {
base := m.theme.TextLabel()
accent := m.theme.TextHighlight()
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
if m.review.redirectURL == "" {
return base.Render(" waiting for authentication link...")
}
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
if err != nil || qrSize > m.widthContent {
body := header + "\n\n" +
base.Render("open this url on your phone to authenticate:") + "\n\n" +
accent.Render(m.review.redirectURL) + "\n\n" +
base.Render("waiting for completion...")
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
}
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
return qr +
header + "\n" +
base.Render("scan or visit to complete authentication") + "\n" +
accent.Render(m.review.redirectURL) + "\n\n" +
base.Render("waiting for completion...")
}
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m = m.SwitchPage(paymentPage)
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)}
m.review.cardJustAdded = false
m.payment.view = 0
m.payment.form = nil
return m, nil
}
if msg.RequiresAction {
m.review.requiresAction = true
m.review.redirectURL = msg.RedirectURL
m.review.orderID = msg.OrderID
return m, m.schedulePollOrderStatus(msg.OrderID)
}
m.Cart = make(map[uint]*models.CartItem)
m.cart.cursor = 0
m.ShippingInfo = nil
@@ -253,57 +205,10 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.review.success = true
return m, nil
case OrderStatusMsg:
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
return m, nil
}
if msg.Err != nil {
// Transient — keep polling.
return m, m.schedulePollOrderStatus(msg.OrderID)
}
switch msg.Status {
case "paid", "shipped", "delivered":
m.Cart = make(map[uint]*models.CartItem)
m.cart.cursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m.review.requiresAction = false
m.review.redirectURL = ""
m.review.success = true
return m, nil
case "failed", "cancelled":
m.review.requiresAction = false
m.review.redirectURL = ""
m.review.authFailed = true
return m, nil
default:
return m, m.schedulePollOrderStatus(msg.OrderID)
}
case tea.KeyMsg:
if m.CheckingOut {
return m, nil
}
if m.review.authFailed {
if msg.String() == "esc" || msg.String() == "enter" {
m.review.authFailed = false
m = m.SwitchPage(paymentPage)
m.payment.view = 0
m.payment.form = nil
return m, nil
}
return m, nil
}
if m.review.requiresAction {
if msg.String() == "esc" {
m.review.requiresAction = false
m.review.redirectURL = ""
m = m.SwitchPage(paymentPage)
m.payment.view = 0
return m, nil
}
return m, nil
}
if m.review.success {
m.review.success = false
m.review.cardJustAdded = false
@@ -329,24 +234,3 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
return m, nil
}
func friendlyCheckoutError(raw error) string {
s := raw.Error()
switch {
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
return "your bank wants to verify this charge, authentication step coming soon."
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
return "this card doesn't support online authentication. Try another card"
case strings.Contains(s, "CARD_EXPIRED"):
return "card expired. Add a new card and try again"
case strings.Contains(s, "CARD_CVC_FAILED"):
return "card cvc incorrect. Check the back of the card and try again"
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
return "insufficient funds on this card"
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
return "card processing error. Try again in a moment"
case strings.Contains(s, "CARD_DECLINED"):
return "card declined"
}
return "checkout failed, try again."
}

View File

@@ -64,11 +64,6 @@ type VisibleError struct {
message string
}
// VisibleNotice is a typed success message displayed to the user as a banner.
type VisibleNotice struct {
message string
}
type Model struct {
// User authentication
User *models.User
@@ -115,7 +110,6 @@ type Model struct {
Loading bool // true when fetching data from API
error *VisibleError
notice *VisibleNotice
APIClient *api.Client
// Rendering
@@ -134,7 +128,6 @@ type Model struct {
shipping shippingState
payment paymentState
account accountState
refund refundState
review reviewState
confirm confirmState
@@ -202,15 +195,6 @@ type reviewState struct {
cardJustAdded bool
cardWasDuplicate bool
success bool
// 3DS / SCA flow: when the backend returns 202 requires_action,
// requiresAction is true and redirectURL is the Stripe-hosted challenge
// page. The TUI shows a QR and polls GetOrderStatus(orderID) until the
// order flips to paid or failed.
requiresAction bool
redirectURL string
orderID uint
authFailed bool
}
type confirmState struct {
@@ -233,7 +217,6 @@ func (m Model) SwitchPage(p page) Model {
m.currentPage = p
m.switched = true
m.error = nil
m.notice = nil
return m
}
@@ -427,18 +410,8 @@ func (m Model) Init() tea.Cmd {
}
type CheckoutResultMsg struct {
OrderID uint
Total int
RequiresAction bool
RedirectURL string
Err error
}
// OrderStatusMsg carries the result of a poll against /orders/{id}/status.
// Emitted on the 2s tick while waiting for 3DS authentication to complete.
type OrderStatusMsg struct {
OrderID uint
Status string
Total int
Err error
}
@@ -650,38 +623,15 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
}
// 3. Convert the cart to an order
outcome, err := m.APIClient.ConvertCart()
order, err := m.APIClient.ConvertCart()
if err != nil {
return CheckoutResultMsg{Err: err}
}
if outcome.RequiresAction {
return CheckoutResultMsg{
OrderID: outcome.OrderID,
RequiresAction: true,
RedirectURL: outcome.RedirectURL,
}
}
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
return CheckoutResultMsg{OrderID: order.ID, Total: order.Total}
}
}
// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while
// waiting for the customer to complete 3DS authentication.
func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
if m.APIClient == nil {
return OrderStatusMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
}
status, err := m.APIClient.GetOrderStatus(orderID)
if err != nil {
return OrderStatusMsg{OrderID: orderID, Err: err}
}
return OrderStatusMsg{OrderID: orderID, Status: status}
})
}
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
if m.APIClient == nil || m.User == nil {
return m, nil

View File

@@ -1,395 +0,0 @@
package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
)
// Refund composer layout constants.
const (
refundMinTextareaRows = 5
refundMaxTextareaRows = 10
// chrome above the textarea inside the modal (title, label, padding, footer).
refundModalChrome = 12
refundModalMaxWidth = 58
refundModalMinWidth = 24
// horizontal chrome the modal subtracts from m.widthContent.
refundModalHGutter = 6
// inner padding around the textarea inside the modal body.
refundTextareaHPad = 4
)
// Nerd-font glyphs used in the refund composer. Requires a Nerd Font in the
// SSH client terminal; renders as tofu otherwise.
const (
refundIconUndo = "\uf0e2" // nf-fa-undo
refundIconTag = "\uf02b" // nf-oct-tag
refundIconComment = "\uf41f" // nf-oct-comment
refundIconKey = "\uf11c" // nf-fa-keyboard_o
)
// refundFocus identifies which field in the refund composer has keyboard focus.
type refundFocus int
const (
refundFocusReason refundFocus = iota
refundFocusMessage
refundFocusCount
)
type refundState struct {
open bool
orderID uint
gen uint64 // bumped each open so late async results from a prior session are ignored
reason string
form *huh.Form
textarea textarea.Model
focusIdx refundFocus
submitting bool
err string
reasonErr string
messageErr string
}
type refundRequestMsg struct {
OrderID uint
Gen uint64
Err error
}
// OpenRefundComposer opens the refund request composer for an order.
func (m Model) OpenRefundComposer(orderID uint) (Model, tea.Cmd) {
state := refundState{
open: true,
orderID: orderID,
gen: m.refund.gen + 1,
}
state.textarea = m.newRefundTextarea()
state.form = m.buildRefundForm(&state)
m.refund = state
return m, m.refund.form.Init()
}
// RefundUpdate handles all input and async results while the refund composer is open.
func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
state := &m.refund
if !state.open {
return m, nil
}
switch msg := msg.(type) {
case refundRequestMsg:
if msg.Gen != state.gen || msg.OrderID != state.orderID {
return m, nil
}
state.submitting = false
if msg.Err != nil {
state.err = fmt.Sprintf("failed to send refund request: %v", msg.Err)
return m, nil
}
gen := state.gen
m.refund = refundState{gen: gen}
m.notice = &VisibleNotice{message: "Refund request sent"}
return m, nil
case tea.WindowSizeMsg:
state.form = m.buildRefundForm(state)
m.resizeRefundTextarea(state)
return m, nil
case tea.KeyMsg:
if state.submitting {
return m, nil
}
switch msg.String() {
case "esc":
gen := state.gen
m.refund = refundState{gen: gen}
return m, nil
case "ctrl+s":
return m, m.submitRefundRequest(state)
case "tab", "shift+tab":
return m, m.setRefundFocus(state, (state.focusIdx+1)%refundFocusCount)
}
}
if state.focusIdx == refundFocusReason {
state.err = ""
state.reasonErr = ""
form, cmd := state.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
state.form = f
}
if state.form.State == huh.StateCompleted {
state.form = m.buildRefundForm(state)
return m, m.setRefundFocus(state, refundFocusMessage)
}
return m, cmd
}
if _, ok := msg.(tea.KeyMsg); ok {
state.err = ""
state.messageErr = ""
}
ta, cmd := state.textarea.Update(msg)
state.textarea = ta
return m, cmd
}
func (m Model) buildRefundForm(state *refundState) *huh.Form {
options := make([]huh.Option[string], 0, len(models.RefundRequestReasons))
for _, reason := range models.RefundRequestReasons {
options = append(options, huh.NewOption(reason, reason))
}
return huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Reason").
Key("reason").
Options(options...).
Value(&state.reason).
Height(len(models.RefundRequestReasons) + 1),
),
).
WithShowErrors(false).
WithShowHelp(false).
WithTheme(m.theme.Form()).
WithLayout(huh.LayoutStack).
WithWidth(m.refundContentWidth())
}
func (m Model) newRefundTextarea() textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.Placeholder = "Describe the issue"
ta.ShowLineNumbers = true
ta.CharLimit = 65536
ta.DynamicHeight = false
ta.SetHeight(m.refundTextareaHeight())
ta.SetWidth(m.refundTextareaWidth())
base := lipgloss.NewStyle()
// Cursor-line uses a darker grey than theme.Border() so the active line
// reads as a subtle wash, not a bright bar competing with the highlight.
cursorLineBG := lipgloss.Color("236")
ta.SetStyles(textarea.Styles{
Focused: textarea.StyleState{
Base: base,
Text: base.Foreground(m.theme.Accent()),
LineNumber: base.Foreground(m.theme.Dim()),
CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()),
CursorLineNumber: base.Foreground(m.theme.Body()),
Placeholder: base.Foreground(m.theme.Dim()),
EndOfBuffer: base.Foreground(m.theme.Dim()),
},
Blurred: textarea.StyleState{
Base: base,
Text: base.Foreground(m.theme.Body()),
LineNumber: base.Foreground(m.theme.Dim()),
CursorLine: base.Foreground(m.theme.Background()),
CursorLineNumber: base.Foreground(m.theme.Dim()),
Placeholder: base.Foreground(m.theme.Dim()),
EndOfBuffer: base.Foreground(m.theme.Dim()),
},
Cursor: textarea.CursorStyle{
Color: m.theme.Highlight(),
Shape: tea.CursorBlock,
Blink: true,
},
})
return ta
}
func (m Model) resizeRefundTextarea(state *refundState) {
state.textarea.SetHeight(m.refundTextareaHeight())
state.textarea.SetWidth(m.refundTextareaWidth())
}
func (m Model) setRefundFocus(state *refundState, focusIdx refundFocus) tea.Cmd {
state.focusIdx = focusIdx
if focusIdx == refundFocusReason {
state.textarea.Blur()
state.form = m.buildRefundForm(state)
return state.form.Init()
}
return state.textarea.Focus()
}
func (m Model) submitRefundRequest(state *refundState) tea.Cmd {
state.err = ""
state.reasonErr = ""
state.messageErr = ""
reason := strings.TrimSpace(state.reason)
message := strings.TrimSpace(state.textarea.Value())
if reason == "" {
state.reasonErr = "select a refund reason"
return nil
}
if reason == models.RefundRequestReasonOther && message == "" {
state.messageErr = "message is required when reason is other"
return nil
}
state.submitting = true
return m.createRefundRequestCmd(state.orderID, state.gen, reason, message)
}
func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, message string) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: fmt.Errorf("not authenticated")}
}
err := m.APIClient.CreateRefundRequest(orderID, api.RefundRequest{
Reason: reason,
Message: message,
})
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: err}
}
}
// RenderRefundOverlay renders the refund composer as a centered modal.
// The modal has no border or background fill — only padding for breathing
// room. lipgloss.Place handles centering against the container.
func (m Model) RenderRefundOverlay() string {
state := &m.refund
contentWidth := m.refundContentWidth()
titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID)
title := m.theme.TextAccent().
Bold(true).
Width(contentWidth).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(m.theme.Highlight()).
Render(titleText)
body := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
m.renderRefundReason(state),
"",
m.renderRefundTextarea(state),
"",
m.renderRefundFooter(state),
)
modal := lipgloss.NewStyle().
Padding(1, 2).
Render(body)
return lipgloss.Place(m.widthContainer, m.heightContainer, lipgloss.Center, lipgloss.Center, modal)
}
func (m Model) renderRefundReason(state *refundState) string {
if state.focusIdx == 0 && state.form != nil {
out := state.form.View()
if state.reasonErr != "" {
out += "\n" + m.theme.TextError().Render(state.reasonErr)
}
return out
}
value := state.reason
if value == "" {
value = "Select a reason"
}
label := m.theme.TextLabel().Render(refundIconTag + " Reason")
selected := m.theme.TextAccent().
Border(lipgloss.HiddenBorder()).
PaddingLeft(1).
Width(m.refundContentWidth()).
Render(value)
out := lipgloss.JoinVertical(lipgloss.Left, label, selected)
if state.reasonErr != "" {
out += "\n" + m.theme.TextError().Render(state.reasonErr)
}
return out
}
func (m Model) renderRefundTextarea(state *refundState) string {
label := "Message (optional)"
placeholder := "Describe the issue"
if state.reason == models.RefundRequestReasonOther {
label = "Message (required)"
placeholder = "Describe the issue..."
}
state.textarea.Placeholder = placeholder
border := m.theme.Border()
if state.focusIdx == refundFocusMessage {
border = m.theme.Highlight()
}
wrapper := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderForeground(border)
contentWidth := m.refundContentWidth()
state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize())
state.textarea.SetHeight(m.refundTextareaHeight())
textareaBox := wrapper.Render(state.textarea.View())
counter := m.theme.TextDim().
Width(contentWidth).
Align(lipgloss.Right).
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
out := lipgloss.JoinVertical(
lipgloss.Left,
m.theme.TextLabel().Render(refundIconComment+" "+label),
textareaBox,
counter,
)
if state.messageErr != "" {
out += "\n" + m.theme.TextError().Render(state.messageErr)
}
return out
}
func (m Model) renderRefundFooter(state *refundState) string {
if state.submitting {
return m.theme.TextAccent().Render("sending refund request...")
}
if state.err != "" {
return m.theme.TextError().Render(state.err)
}
key := m.theme.TextHighlight().Bold(true)
sep := m.theme.TextDim().Render(" · ")
dim := m.theme.TextDim()
return dim.Render(refundIconKey+" ") +
key.Render("tab") + dim.Render(" next") + sep +
key.Render("shift+tab") + dim.Render(" prev") + sep +
key.Render("ctrl+s") + dim.Render(" submit") + sep +
key.Render("esc") + dim.Render(" cancel")
}
func (m Model) refundContentWidth() int {
width := min(m.widthContent-refundModalHGutter, refundModalMaxWidth)
if width < refundModalMinWidth {
width = max(1, m.widthContent-refundTextareaHPad)
}
return width
}
func (m Model) refundTextareaWidth() int {
return max(1, m.refundContentWidth()-refundTextareaHPad)
}
func (m Model) refundTextareaHeight() int {
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
}

View File

@@ -2,12 +2,11 @@ package tui
import (
"fmt"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"time"
tea "charm.land/bubbletea/v2"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -37,10 +36,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.payment.form != nil {
m.payment.form.form = m.buildPaymentForm(m.payment.form)
}
if m.refund.open {
m.refund.form = m.buildRefundForm(&m.refund)
m.resizeRefundTextarea(&m.refund)
}
}
return m, nil
}
@@ -56,15 +51,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case VisibleError:
m.error = &msg
m.notice = nil
return m, nil
case VisibleNotice:
m.notice = &msg
m.error = nil
return m, nil
case error:
m.error = &VisibleError{message: msg.Error()}
m.notice = nil
if m.currentPage == shopPage || m.currentPage == cartPage {
return m, m.fetchCartCmd
}
@@ -178,14 +167,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.shipping.form != nil || m.payment.form != nil {
break
}
// Refund overlay owns input when open; dispatched below before page handlers.
if m.refund.open {
break
}
// Dismiss banners with ESC.
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
// Dismiss error banner with ESC
if msg.String() == "esc" && m.error != nil {
m.error = nil
m.notice = nil
return m, nil
}
// Global navigation
@@ -208,12 +192,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// non-globals. fall through to page dispatch below
}
// Refund overlay intercepts all input after global handling.
if m.refund.open {
return m.RefundUpdate(msg)
}
// Page-specifics
switch m.currentPage {
case shopPage:

View File

@@ -82,16 +82,8 @@ func (m Model) renderString() string {
content = loadingStyle.Render("Loading products from API...")
} else if m.error != nil {
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
// soft-wrap long messages instead of clipping.
if m.widthContent > 4 {
errorStyle = errorStyle.Width(m.widthContent - 4)
}
errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
} 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)
} else {
content = m.buildPageContent(availableContentHeight)
}
@@ -123,10 +115,6 @@ func (m Model) renderString() string {
MaxHeight(m.heightContainer).
Render(child)
if m.refund.open {
constrained = m.RenderRefundOverlay()
}
// Center the entire container in the terminal viewport
return lipgloss.Place(
m.viewportWidth,