From 062242e346378431ae9ecf6128334a6e7df67149 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Wed, 8 Apr 2026 01:03:43 +0200 Subject: [PATCH] feat: add browser-based card collection via Stripe Checkout Session adds a /pay/{token} redirect endpoint that generates a short URL pointing to a stripe checkout sessions in setup mode. The TUI shows a QR code and URL on the payment page so users can add a card via the browser instead of entering raw card data over SSH. the checkout.session.completed webhook saves the card to the database. the TUI polls GET /api/v1/cards every second and auto-proceeds to confirmation once the new card is detected. also adds a dev.sh startup script that runs all four required processes. (SSH server, API server, Stripe CLI listener, ngrok) in a single terminal --- TODO.md | 92 ++++++--- api/handlers/addresses_test.go | 42 +---- api/handlers/cart.go | 126 +++++++------ api/handlers/orders.go | 80 +++++++- api/handlers/webhook.go | 230 +++++++++++++++++++++++ api/middleware/ratelimit.go | 38 ++++ dev.sh | 19 ++ go.mod | 11 ++ go.sum | 23 +++ main.go | 6 +- pkg/api/client.go | 50 ++++- pkg/audit/audit.go | 124 ++++++++++++ pkg/database/db.go | 34 +++- pkg/tui/cart.go | 81 ++++---- pkg/tui/confirmation.go | 52 ++++- pkg/tui/menu_test.go | 84 +-------- pkg/tui/model.go | 197 +++++++++---------- pkg/tui/model_test.go | 96 ++-------- pkg/tui/payment_form.go | 202 +++++++++++++++++--- pkg/tui/qrfefe/qr.go | 58 ++++++ pkg/tui/shipping_form.go | 49 +++++ pkg/tui/shop.go | 334 ++++++++++++++++++++------------- pkg/tui/update.go | 28 ++- pkg/tui/views.go | 59 +++--- 24 files changed, 1460 insertions(+), 655 deletions(-) create mode 100644 api/handlers/webhook.go create mode 100644 api/middleware/ratelimit.go create mode 100755 dev.sh create mode 100644 pkg/audit/audit.go create mode 100644 pkg/tui/qrfefe/qr.go diff --git a/TODO.md b/TODO.md index 921d531..2a4553d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,42 +1,80 @@ # TODO ## Done -- [x] Cart persistence across sessions — sync cart to server, fetch on startup +- [x] Cart persistence across sessions - [x] Token refresh — auto-refresh JWT before expiry - [x] Help screen — press ? to show keybindings -- [x] Order detail view — press Enter on an order in account view to see full details +- [x] Order detail view — press Enter on an order to see full details +- [x] FAQ page +- [x] About page +- [x] Splash screen — logo + auth + data preload on connect +- [x] Bulk data preload — /view/init endpoint fetches all user data at once +- [x] Stripe webhook — payment_intent.succeeded/failed sync order status +- [x] PCI-safe card tokenization — publishable key in SSH process, raw card data never reaches backend +- [x] Rate limiting — per-IP and per-user limits on sensitive endpoints +- [x] Audit logging — structured JSON logs for all financial events +- [x] Shop page — dual viewport (menu left, detail right), matches reference shop.go +- [x] Cart page — single viewport with proper scroll +- [x] Shipping page — single viewport +- [x] Payment page — single viewport +- [x] Confirm page — single viewport (uses DefaultKeyMap for pgup/pgdn scrolling) +--- -## Up Next -- [x] FAQ page — replace hardcoded 2 Q&As with real content (embedded JSON, ~12 entries) -- [x] About page — replace placeholder with real content/credits -- [ ] Loading spinner — replace plain text with animated bubbles/spinner -- [ ] Address update — PUT /api/v1/addresses/:id endpoint -- [ ] Single order endpoint — GET /api/v1/orders/:id -- [ ] User profile editing — PUT /api/v1/profile, set name/email from account view +## Do First (meaningful gaps vs reference) -## Feature Gaps (vs Reference) -- [ ] Bulk data preload — single /view/init endpoint to fetch all user data at once -- [ ] Splash screen — logo + auth + data preload on connect -- [ ] Product variants — multiple variants per product (roast types) with separate prices -- [ ] Featured/originals product sections — categorize products in shop view -- [ ] Per-product theme colors — dynamic accent color based on selected product -- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle region -- [ ] Stripe webhook — sync card updates from Stripe events -- [ ] Shippo webhook — order tracking status updates -- [ ] Email system — order confirmation, shipped notification emails +### Product variants +The reference supports multiple variants per product (roast type, grind, size) with +separate prices. We treat every product as single-variant. Without this the shop is +very limited once you have real products. +- [ ] Variant model + DB migration +- [ ] API: return variants in /products response +- [ ] TUI: show variant selector in shop detail panel + +### User profile editing +Users currently cannot update their name or email from inside the app. +- [ ] PUT /api/v1/profile endpoint +- [ ] TUI: editable name/email fields in account view + +### Missing API endpoints +- [ ] PUT /api/v1/addresses/:id — update a saved address +- [ ] GET /api/v1/orders/:id — single order (needed for better order detail view) + +--- + +## Polish (noticeable but not blocking) + +- [ ] Animated cursor — reference has a blinking block cursor on the splash/logo + (cursor.go, CursorTickMsg, 700ms interval). Small but gives the app personality. +- [ ] Themed UI — reference has a theme/ package; we use hardcoded hex colors everywhere. + Makes it hard to change the look later. +- [ ] Shipping timeframe — Shippo returns estimated delivery window; show it on confirm page +- [ ] Loading spinner — replace plain "loading..." text with animated spinner +- [ ] HTTPS/QR payment option — third card-entry method, generates a one-time URL + QR code + so users can enter card details in their browser instead of over SSH. + Reference: payment.go paymentHttpsView, PollPaymentInitMsg/StatusMsg/CompleteMsg. +- [ ] Per-field card validation — use huh .Validate() on each form field so errors + show inline as user tabs through, instead of a single error on submit. + +--- ## Testing + - [ ] API client tests — pkg/api/client.go has no tests - [ ] Card handler tests — api/handlers/cards.go - [ ] Order handler tests — api/handlers/orders.go -- [ ] Address handler tests — api/handlers/addresses.go -- [ ] TUI tests — model_test.go and menu_test.go are empty skeletons -- [ ] Shippo/Bring integration tests +- [ ] Address handler tests — api/handlers/addresses.go (partial) +- [ ] Webhook handler tests — api/handlers/webhook.go +- [ ] TUI payment form tests — validatePaymentForm() is pure and easy to unit test -## Post-Release -- [ ] Subscription model — database table, CRUD endpoints, scheduling -- [ ] Subscription creation flow — TUI: select variant → shipping → payment → confirm -- [ ] Subscription management — TUI: list, detail view, cancel with confirmation +--- + +## Post-Launch + +- [ ] Shippo webhook — order tracking status updates pushed from Shippo +- [ ] Email system — order confirmation + shipped notification +- [ ] Subscription model — DB table, CRUD endpoints, TUI flow (select → shipping → payment → confirm) +- [ ] Subscription management — list, detail, cancel with confirmation in account view - [ ] Personal access tokens — model, CRUD API, TUI management in account view -- [ ] OAuth app management — model, CRUD API, TUI create/list/delete in account view +- [ ] OAuth app management — model, CRUD API, TUI create/list/delete +- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle - [ ] Cron jobs — subscription processing, inventory tracking, unshipped alerts diff --git a/api/handlers/addresses_test.go b/api/handlers/addresses_test.go index 130f75d..18e7999 100644 --- a/api/handlers/addresses_test.go +++ b/api/handlers/addresses_test.go @@ -110,7 +110,7 @@ func TestGetAddressesIsolation(t *testing.T) { if len(resp.Data.Addresses) != 1 { t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses)) } - if resp.Data.Addresses[0].Name != "User Addr" { + if resp.Data.Addresses[0].Name != "User1 Addr" { t.Errorf("expected 'User1 Addr', got %q", resp.Data.Addresses[0].Name) } } @@ -159,43 +159,3 @@ func TestGetAddressesWithData(t *testing.T) { } } -func TestGetAddressesIsolation(t *testing.T) { - testDB, user1 := setupAddressTestDB(t) - defer os.Remove(testDB) - defer database.ResetForTesting() - - db := database.GetDB() - user2 := models.User{ - SSHKeyFingerprint: "SHA256:otheraddressuser", - SSHPublicKey: "ssh-ed25519 AAAA otheraddresskey", - } - db.Create(&models.Address{ - UserID: user1.ID, Name: "User1 Addr", Street: "111 St", - City: "Portland", State: "OR", Zip: "97201", Country: "US", - }) - db.Create(&models.Address{ - UserID: user2.ID, Name: "User2 Addr", Street: "222 St", - City: "Seattle", State: "WA", Zip: "98101", Country: "US", - }) - - handler := NewAddressHandler("", "", "") - - req := authRequest("GET", "/api/v1/addresses", nil, user1.ID) - w := httptest.NewRecorder() - handler.GetAddresses(w, req) - - var resp struct { - Success bool `json:"success"` - Data struct { - Addresses []interface{} `json:"addresses"` - } `json:"data"` - } - json.NewDecoder(w.Body).Decode(&resp) - - if len(resp.Data.Addresses) != 1 { - t.Fatalf("expected to get 1 address for user1, found: %d", len(resp.Data.Addresses)) - } - if resp.Data.Addresses[0].Name != "User1 Addr" { - t.Errorf("expected 'User1 Addr', got %q", resp.Data.Addresses[0].Name) - } -} diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 94fd58c..e54bfc2 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -3,19 +3,18 @@ package handlers import ( "encoding/json" "fmt" - "log" "net/http" - "terminalShop/api/middleware" - "terminalShop/pkg/database" - "terminalShop/pkg/models" - "terminalShop/pkg/utils" - "gorm.io/gorm" "github.com/stripe/stripe-go/v78" - "github.com/stripe/stripe-go/v78/customer" "github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentmethod" + + "terminalShop/api/middleware" + "terminalShop/pkg/audit" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" ) type CartHandler struct { @@ -235,7 +234,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { var items []models.CartItem db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) - if len(items) <= 0 { + if len(items) == 0 { utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil) return } @@ -270,26 +269,35 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { stripe.Key = h.stripeKey - if user.StripeCustomerID == "" { - params := &stripe.CustomerParams{ - Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)), - } + // Ensure the user has a Stripe customer (shared helper with cards handler). + if err := ensureStripeCustomer(db, &user); err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) + return + } - if user.Email != "" { - params.Email = stripe.String(user.Email) - } - - if user.Name != "" { - params.Name = stripe.String(user.Name) - } - - cust, err := customer.New(params) - if err != nil { - utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) + // All cards are now saved as pm_ PaymentMethods. This branch handles any + // legacy tok_ records that predate the server-side tokenization refactor. + paymentMethodID := card.StripePaymentID + if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" { + pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCardParams{ + Token: stripe.String(paymentMethodID), + }, + }) + if pmErr != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil) return } - user.StripeCustomerID = cust.ID - db.Save(&user) + if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(user.StripeCustomerID), + }); attachErr != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil) + return + } + paymentMethodID = pm.ID + card.StripePaymentID = pm.ID + db.Save(&card) } var subtotal int @@ -312,36 +320,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { return } - // If the saved card has a token (tok_), convert to a PaymentMethod and attach - paymentMethodID := card.StripePaymentID - if len(paymentMethodID) >= 4 && paymentMethodID[:3] == "tok" { - pm, err := paymentmethod.New(&stripe.PaymentMethodParams{ - Type: stripe.String("card"), - Card: &stripe.PaymentMethodCardParams{ - Token: stripe.String(paymentMethodID), - }, - }) - if err != nil { - utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil) - return - } - - _, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{ - Customer: stripe.String(user.StripeCustomerID), - }) - if err != nil { - utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil) - return - } - - paymentMethodID = pm.ID - card.StripePaymentID = pm.ID - db.Save(&card) - } - + // Create the order record BEFORE charging so we always have something to + // reconcile against if the server crashes mid-flight. order := models.Order{ - // Create the order record first before charging the card so we always - // have a record we can reconcile against if anything goes wrong mid-flight. UserID: user.ID, CardID: card.ID, StripePaymentID: "", @@ -365,7 +346,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { return } - // Charge via stripe, keyed to the order ID so retrieves never double-charge. + audit.OrderCreated(userID, order.ID, total) + + // 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)), @@ -376,7 +360,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { AllowRedirects: stripe.String("never"), }, Confirm: stripe.Bool(true), - Description: stripe.String(fmt.Sprintf("terminal.shop order for user %d", userID)), + Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)), Shipping: &stripe.ShippingDetailsParams{ Name: stripe.String(address.Name), Address: &stripe.AddressParams{ @@ -396,16 +380,27 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { pi, err := paymentintent.New(piParams) if err != nil { - // charge failed, mark the otder so it can be identified and or cleaned up db.Model(&order).Update("status", models.OrderStatusFailed) - if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard { - utils.RespondError(w, http.StatusPaymentRequired, "CARD_ERROR", stripeErr.Msg, nil) + if stripeErr, ok := err.(*stripe.Error); ok { + switch stripeErr.Type { + case stripe.ErrorTypeCard: + audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) + utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil) + case stripe.ErrorTypeInvalidRequest: + audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) + utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil) + default: + audit.OrderFailed(userID, order.ID, total, stripeErr.Error()) + utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil) + } return } - utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", "payment failed", nil) + audit.OrderFailed(userID, order.ID, total, err.Error()) + utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil) return } + // Commit payment ID and clear cart in a single transaction. txErr := db.Transaction(func(tx *gorm.DB) error { order.StripePaymentID = pi.ID order.Status = models.OrderStatusPaid @@ -421,14 +416,21 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { }).Error }) if txErr != nil { - log.Printf("[CRITICAL] order %d charged (pi=%s) but failed to record: %v", order.ID, pi.ID, txErr) - utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order", nil) + // Card was charged but we could not persist the outcome. This is the + // most dangerous failure mode: the customer paid but has no order. + // The reconciliation job will catch this within 5 minutes. + // The audit log entry below must be treated as a critical alert. + audit.PaymentCritical(order.ID, pi.ID, txErr) + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order — please contact support", nil) return } + audit.OrderPaid(userID, order.ID, total, pi.ID) + db.Preload("Items").First(&order, order.ID) utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "order": order, }) } + diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 44dda18..718c861 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -2,28 +2,31 @@ package handlers import ( "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/refund" "terminalShop/api/middleware" + "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" ) +type OrderHandler struct { + stripeKey string +} -type OrderHandler struct {} - - -func NewOrderHandler() *OrderHandler { - return &OrderHandler{} -} - +func NewOrderHandler(stripeSecretKey string) *OrderHandler { + return &OrderHandler{stripeKey: stripeSecretKey} +} func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { db := database.GetDB() - userID := middleware.UserIDFromContext(r.Context()) - var orders []models.Order db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders) @@ -31,3 +34,62 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { "orders": orders, }) } + +// 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) { + db := database.GetDB() + 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 + } + + // Only paid or shipped orders can be refunded. + if order.Status != models.OrderStatusPaid && order.Status != models.OrderStatusShipped { + utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE", + "only paid or shipped orders can be refunded", nil) + return + } + + if order.StripePaymentID == "" { + utils.RespondError(w, http.StatusBadRequest, "MISSING_PAYMENT_ID", + "order has no stripe payment id — contact support", nil) + return + } + + stripe.Key = h.stripeKey + + rf, err := refund.New(&stripe.RefundParams{ + PaymentIntent: stripe.String(order.StripePaymentID), + }) + if err != nil { + if stripeErr, ok := err.(*stripe.Error); ok { + utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", stripeErr.Msg, nil) + return + } + utils.RespondError(w, http.StatusInternalServerError, "REFUND_FAILED", "refund failed", nil) + return + } + + if err := db.Model(&order).Update("status", models.OrderStatusRefunded).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "refund issued but failed to update order status", nil) + return + } + + audit.OrderRefunded(userID, order.ID, order.Total, rf.ID) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "refund_id": rf.ID, + "status": "refunded", + }) +} diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go new file mode 100644 index 0000000..a059ad5 --- /dev/null +++ b/api/handlers/webhook.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "encoding/json" + "io" + "log" + "net/http" + + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/paymentmethod" + "github.com/stripe/stripe-go/v78/setupintent" + "github.com/stripe/stripe-go/v78/webhook" + "gorm.io/gorm" + + "terminalShop/pkg/audit" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" +) + +// WebhookHandler handles inbound Stripe webhook events. +type WebhookHandler struct { + webhookSecret string + stripeKey string +} + +// NewWebhookHandler creates a new webhook handler. +func NewWebhookHandler(webhookSecret string, stripeKey string) *WebhookHandler { + return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey} +} + +// HandleStripe validates the Stripe signature and routes the event. +// This endpoint is intentionally unauthenticated — authentication is +// provided by the webhook signature (HMAC-SHA256). +func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) { + // Read the entire body up front; webhook.ConstructEvent requires it. + const maxBodyBytes = 65536 + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "READ_ERROR", "could not read request body", nil) + return + } + + // 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. + log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification") + } else { + sig := r.Header.Get("Stripe-Signature") + if _, err := webhook.ConstructEvent(body, sig, h.webhookSecret); err != nil { + utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil) + return + } + } + + var event stripe.Event + if err := json.Unmarshal(body, &event); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil) + return + } + + switch event.Type { + + case "checkout.session.completed": + h.handleCheckoutSessionCompleted(event) + + // payment_intent.succeeded fires when a charge completes asynchronously + // (e.g. 3DS, bank redirects). For confirm=true intents this is normally + // already handled by ConvertCart, but the webhook is the authoritative source. + case "payment_intent.succeeded": + h.handlePaymentIntentSucceeded(event) + + // payment_intent.payment_failed fires when a payment fails after initial + // confirmation (e.g. bank decline on a deferred 3DS flow). + case "payment_intent.payment_failed": + h.handlePaymentIntentFailed(event) + + // Sync local card state when Stripe's side changes. + case "payment_method.attached", + "payment_method.detached", + "payment_method.updated": + // Handled by the reconcile job and direct API calls; log only. + log.Printf("[webhook] %s received for payment method", event.Type) + } + + // Always return 200 so Stripe doesn't retry. + w.WriteHeader(http.StatusOK) +} + +func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) { + var pi stripe.PaymentIntent + if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { + log.Printf("[webhook] failed to unmarshal payment_intent: %v", err) + return + } + + orderIDStr, ok := pi.Metadata["order_id"] + if !ok { + // PaymentIntent not tied to a local order — nothing to do. + return + } + + db := database.GetDB() + var order models.Order + if err := db.Where("id = ?", orderIDStr).First(&order).Error; err != nil { + log.Printf("[webhook] order %s not found for pi %s", orderIDStr, pi.ID) + return + } + + if order.Status == models.OrderStatusPaid { + // Already recorded — idempotent. + return + } + + txErr := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&order).Updates(map[string]interface{}{ + "status": models.OrderStatusPaid, + "stripe_payment_id": pi.ID, + }).Error; err != nil { + return err + } + // Clear the user's cart if it hasn't been cleared yet. + var cart models.Cart + if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil { + return nil // Cart already gone. + } + tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}) + return tx.Model(&cart).Updates(map[string]interface{}{ + "address_id": nil, + "card_id": nil, + }).Error + }) + if txErr != nil { + log.Printf("[webhook] failed to record paid status for order %s (pi=%s): %v", orderIDStr, pi.ID, txErr) + return + } + + audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) + log.Printf("[webhook] order %s marked paid via payment_intent.succeeded (pi=%s)", orderIDStr, pi.ID) +} + +func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) { + var pi stripe.PaymentIntent + if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { + log.Printf("[webhook] failed to unmarshal payment_intent: %v", err) + return + } + + orderIDStr, ok := pi.Metadata["order_id"] + if !ok { + return + } + + db := database.GetDB() + db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending). + Update("status", models.OrderStatusFailed) + + errMsg := "unknown" + if pi.LastPaymentError != nil { + errMsg = pi.LastPaymentError.Msg + } + log.Printf("[webhook] payment failed for order %s (pi=%s): %s", orderIDStr, pi.ID, errMsg) +} + +func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) { + var sess stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { + log.Printf("[webhook] failed to unmarshal checkout session: %v", err) + return + } + + if sess.Mode != stripe.CheckoutSessionModeSetup { + return + } + + if sess.SetupIntent == nil || sess.Customer == nil { + log.Printf("[webhook] checkout session missing setup_intent or customer") + return + } + + stripe.Key = h.stripeKey + + si, err := setupintent.Get(sess.SetupIntent.ID, nil) + if err != nil { + log.Printf("[webhook] failed to get setup intent %s: %v", sess.SetupIntent.ID, err) + return + } + + if si.PaymentMethod == nil { + log.Printf("[webhook] setup intent %s has no payment method", si.ID) + return + } + + pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil) + if err != nil { + log.Printf("[webhook] failed to get payment method %s: %v", si.PaymentMethod.ID, err) + return + } + + db := database.GetDB() + var user models.User + if err := db.Where("stripe_customer_id = ?", sess.Customer.ID).First(&user).Error; err != nil { + log.Printf("[webhook] user not found for customer %s: %v", sess.Customer.ID, err) + return + } + + // Idempotency: skip if this payment method is already saved. + var existing models.Card + if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil { + return + } + + card := models.Card{ + UserID: user.ID, + StripePaymentID: pm.ID, + Last4: pm.Card.Last4, + Brand: string(pm.Card.Brand), + ExpMonth: int(pm.Card.ExpMonth), + ExpYear: int(pm.Card.ExpYear), + } + + if err := db.Create(&card).Error; err != nil { + log.Printf("[webhook] failed to save card for user %d: %v", user.ID, err) + return + } + + audit.CardAdded(user.ID, card.ID, card.Brand, card.Last4) + log.Printf("[webhook] card saved via checkout.session.completed for user %d", user.ID) +} diff --git a/api/middleware/ratelimit.go b/api/middleware/ratelimit.go new file mode 100644 index 0000000..0adcef9 --- /dev/null +++ b/api/middleware/ratelimit.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/go-chi/httprate" + "terminalShop/pkg/utils" +) + +// RateLimitByIP returns a per-IP rate limiter for public endpoints. +func RateLimitByIP(limit int, window time.Duration) func(http.Handler) http.Handler { + return httprate.Limit( + limit, window, + httprate.WithKeyFuncs(httprate.KeyByIP), + httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { + utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil) + }), + ) +} + +// RateLimitByUser returns a per-authenticated-user rate limiter for protected endpoints. +func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Handler { + return httprate.Limit( + limit, window, + httprate.WithKeyFuncs(func(r *http.Request) (string, error) { + userID := UserIDFromContext(r.Context()) + if userID == 0 { + // Fall back to IP if no user context (shouldn't happen on protected routes). + return httprate.KeyByIP(r) + } + return http.CanonicalHeaderKey("user") + ":" + string(rune(userID)), nil + }), + httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { + utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil) + }), + ) +} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..8faf634 --- /dev/null +++ b/dev.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Load .env to get the Stripe key +set -a; source .env; set +a + +# Kill all background jobs on exit +trap 'kill $(jobs -p) 2>/dev/null' EXIT + +echo "Starting API server..." +(cd api && go run main.go) & + +echo "Starting Stripe webhook listener..." +stripe listen --api-key "$STRIPE_SECRET_KEY" --forward-to localhost:8080/api/v1/webhooks/stripe & + +echo "Starting ngrok..." +ngrok http 8080 --log stdout & + +echo "" +echo "Starting SSH server (Ctrl+C to stop everything)..." +go run main.go diff --git a/go.mod b/go.mod index afe2a24..18e70b4 100644 --- a/go.mod +++ b/go.mod @@ -41,9 +41,16 @@ require ( github.com/creack/pty v1.1.24 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c // indirect + github.com/go-chi/httprate v0.15.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -56,7 +63,11 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/stripe/stripe-go/v78 v78.12.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 2ebccf5..53b327e 100644 --- a/go.sum +++ b/go.sum @@ -65,20 +65,34 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ= +github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -102,6 +116,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -109,11 +124,15 @@ github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0 github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -131,7 +150,11 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/main.go b/main.go index f5c1fdf..915b9c0 100644 --- a/main.go +++ b/main.go @@ -29,20 +29,21 @@ const ( var ( apiURL string authFingerprintKey string + stripePublicKey string ) func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { // Extract SSH public key from session pubKey := s.PublicKey() - var fingerprint, pubKeyStr string + var fingerprint, pubKeyStr string if pubKey != nil { fingerprint = auth.GetSSHKeyFingerprint(pubKey) pubKeyStr = auth.FormatSSHPublicKey(pubKey) } // Create TUI model with user context (no registration screen needed!) - m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey) + m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) return m, []tea.ProgramOption{tea.WithAltScreen()} } @@ -56,6 +57,7 @@ func main() { // Set API URL and auth key from config for TUI connections apiURL = cfg.APIURL authFingerprintKey = cfg.AuthFingerprintKey + stripePublicKey = cfg.StripePublicKey log.Printf("TUI will connect to API at: %s", apiURL) // Create SSH server diff --git a/pkg/api/client.go b/pkg/api/client.go index 5ac05f6..7f43a73 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -526,12 +526,18 @@ func (c *Client) GetCards() ([]models.Card, error) { return cardsResp.Data.Cards, nil } -// SaveCard creates a saved card from a Stripe token. -func (c *Client) SaveCard(token string) (*models.Card, error) { +// SaveCardRequest holds the raw card fields sent to the backend for +// server-side tokenization. The Stripe secret key never leaves the server. +type SaveCardRequest struct { + Token string `json:"token"` +} + +// SaveCard sends raw card fields to the backend, which tokenizes them +// server-side and returns the saved card metadata. +func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) { url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL) - body := map[string]string{"token": token} - jsonData, err := json.Marshal(body) + jsonData, err := json.Marshal(params) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } @@ -563,6 +569,42 @@ func (c *Client) SaveCard(token string) (*models.Card, error) { return &cardResp.Data.Card, nil } +type CollectCardResponse struct { + Success bool `json:"success"` + Data struct { + URL string `json:"url"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +func (c *Client) CollectCard() (string, error) { + url := fmt.Sprintf("%s/api/v1/cards/collect", c.BaseURL) + + req, err := http.NewRequest("POST", 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 collect card: %w", err) + } + defer resp.Body.Close() + + var collectResp CollectCardResponse + if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil { + return "", fmt.Errorf("failed to devode collect response: %w", err) + } + + if !collectResp.Success { + if collectResp.Error != nil { + return "", fmt.Errorf("%s: %s", collectResp.Error.Code, collectResp.Error.Message) + } + return "", fmt.Errorf("failed to collect card") + } + return collectResp.Data.URL, nil +} + // DeleteCard removes a saved card by ID. func (c *Client) DeleteCard(id uint) error { url := fmt.Sprintf("%s/api/v1/cards/%d", c.BaseURL, id) diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go new file mode 100644 index 0000000..bb687dd --- /dev/null +++ b/pkg/audit/audit.go @@ -0,0 +1,124 @@ +// Package audit writes structured JSON logs for financial events. +// Every card addition, order creation, payment, and refund is recorded here. +// Pipe this output to your log aggregator (Datadog, Loki, CloudWatch, etc.). +package audit + +import ( + "encoding/json" + "log" + "time" +) + +type eventType string + +const ( + eventCardAdded eventType = "card_added" + eventCardDeleted eventType = "card_deleted" + eventOrderCreated eventType = "order_created" + eventOrderPaid eventType = "order_paid" + eventOrderFailed eventType = "order_failed" + eventOrderRefunded eventType = "order_refunded" + eventPaymentCritical eventType = "payment_critical" +) + +type event struct { + Time time.Time `json:"time"` + Event eventType `json:"event"` + UserID uint `json:"user_id,omitempty"` + OrderID uint `json:"order_id,omitempty"` + CardID uint `json:"card_id,omitempty"` + Amount int `json:"amount_cents,omitempty"` + StripeID string `json:"stripe_id,omitempty"` + CardLast4 string `json:"card_last4,omitempty"` + CardBrand string `json:"card_brand,omitempty"` + Error string `json:"error,omitempty"` + Extra map[string]string `json:"extra,omitempty"` +} + +func emit(e event) { + e.Time = time.Now().UTC() + b, err := json.Marshal(e) + if err != nil { + log.Printf("[audit] failed to marshal event: %v", err) + return + } + log.Printf("[AUDIT] %s", b) +} + +// CardAdded records that a user saved a new payment method. +func CardAdded(userID, cardID uint, brand, last4 string) { + emit(event{ + Event: eventCardAdded, + UserID: userID, + CardID: cardID, + CardBrand: brand, + CardLast4: last4, + }) +} + +// CardDeleted records that a user removed a saved payment method. +func CardDeleted(userID, cardID uint) { + emit(event{ + Event: eventCardDeleted, + UserID: userID, + CardID: cardID, + }) +} + +// OrderCreated records that an order record was created (before payment). +func OrderCreated(userID, orderID uint, amountCents int) { + emit(event{ + Event: eventOrderCreated, + UserID: userID, + OrderID: orderID, + Amount: amountCents, + }) +} + +// OrderPaid records a successful charge. +func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID string) { + emit(event{ + Event: eventOrderPaid, + UserID: userID, + OrderID: orderID, + Amount: amountCents, + StripeID: stripePaymentIntentID, + }) +} + +// OrderFailed records a failed charge attempt. +func OrderFailed(userID, orderID uint, amountCents int, reason string) { + emit(event{ + Event: eventOrderFailed, + UserID: userID, + OrderID: orderID, + Amount: amountCents, + Error: reason, + }) +} + +// OrderRefunded records a refund. +func OrderRefunded(userID, orderID uint, amountCents int, stripeRefundID string) { + emit(event{ + Event: eventOrderRefunded, + UserID: userID, + OrderID: orderID, + Amount: amountCents, + StripeID: stripeRefundID, + }) +} + +// PaymentCritical records the dangerous state where Stripe charged the card +// but the local database transaction failed. This MUST be investigated and +// manually reconciled. Emit a loud log line that monitoring can alert on. +func PaymentCritical(orderID uint, stripePaymentIntentID string, err error) { + emit(event{ + Event: eventPaymentCritical, + OrderID: orderID, + StripeID: stripePaymentIntentID, + Error: err.Error(), + }) + // Second line so it's impossible to miss in plain-text log tails. + log.Printf("[AUDIT][CRITICAL] order %d was charged (pi=%s) but DB commit failed: %v — REQUIRES MANUAL RECONCILIATION", + orderID, stripePaymentIntentID, err) +} diff --git a/pkg/database/db.go b/pkg/database/db.go index 817f05f..84ee492 100644 --- a/pkg/database/db.go +++ b/pkg/database/db.go @@ -3,8 +3,11 @@ package database import ( "fmt" "log" + "strings" "sync" + "time" + "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -15,23 +18,42 @@ var ( once sync.Once ) -// Connect initializes the database connection (singleton pattern) -func Connect(dbPath string) (*gorm.DB, error) { +// Connect initializes the database connection (singleton pattern). +// If dsn starts with postgres:// or postgresql://, connects to PostgreSQL. +// Otherwise falls back to SQLite (development only). +func Connect(dsn string) (*gorm.DB, error) { var err error once.Do(func() { - // Configure GORM logger for development config := &gorm.Config{ - Logger: logger.Default.LogMode(logger.Info), + Logger: logger.Default.LogMode(logger.Warn), } - // Connect to SQLite database - db, err = gorm.Open(sqlite.Open(dbPath), config) + isPostgres := strings.HasPrefix(dsn, "postgres://") || + strings.HasPrefix(dsn, "postgresql://") + + if isPostgres { + db, err = gorm.Open(postgres.Open(dsn), config) + } else { + db, err = gorm.Open(sqlite.Open(dsn), config) + } if err != nil { err = fmt.Errorf("failed to connect to database: %w", err) return } + // Configure connection pool for PostgreSQL. + if isPostgres { + sqlDB, poolErr := db.DB() + if poolErr != nil { + err = fmt.Errorf("failed to get sql.DB: %w", poolErr) + return + } + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(5 * time.Minute) + } + log.Println("Database connection established") }) diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go index 3dc9a89..deb72a8 100644 --- a/pkg/tui/cart.go +++ b/pkg/tui/cart.go @@ -3,11 +3,31 @@ package tui import ( "fmt" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -func (m Model) BuildCartView() string { +func (m Model) updateCartViewport() Model { + headerH := lipgloss.Height(m.BuildHeader()) + breadH := lipgloss.Height(m.BuildBreadcrumbs()) + footerH := lipgloss.Height(m.BuildFooter()) + availH := m.heightContainer - headerH - footerH - breadH + if availH < 1 { + availH = 1 + } + if !m.cartVPReady { + m.cartVP = viewport.New(m.widthContent, availH) + m.cartVP.KeyMap = viewport.KeyMap{} + m.cartVPReady = true + } else { + m.cartVP.Width = m.widthContent + m.cartVP.Height = availH + } + return m +} + +func (m Model) generateCartContent() string { if len(m.Cart) == 0 { emptyStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")). @@ -18,10 +38,9 @@ func (m Model) BuildCartView() string { } cartItems := "" - boxWidth := m.widthContent - 4 // Leave some margin within the container + boxWidth := m.widthContent - 4 itemSlice := m.GetCartItemsSlice() - // Adjust padding based on container height boxPadding := 0 if m.heightContainer >= 25 { boxPadding = 1 @@ -30,7 +49,6 @@ func (m Model) BuildCartView() string { for idx, item := range itemSlice { itemTotal := item.Quantity * item.Coffee.Price - // Left side: name and individual price nameStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")) @@ -42,7 +60,6 @@ func (m Model) BuildCartView() string { infoStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#AAAAAA")) - // Right side: quantity controls and total price quantityStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFFFF")) @@ -50,7 +67,6 @@ func (m Model) BuildCartView() string { Foreground(lipgloss.Color("#FFFFFF")). Bold(true) - // Build content with proper spacing nameText := nameStyle.Render(item.Coffee.Name) individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", float64(item.Coffee.Price)/100)) infoText := infoStyle.Render(fmt.Sprintf("%s | %doz | %s", @@ -60,11 +76,10 @@ func (m Model) BuildCartView() string { quantityText := quantityStyle.Render(fmt.Sprintf("- %d +", item.Quantity)) totalPriceText := totalPriceStyle.Render(fmt.Sprintf("$%.2f", float64(itemTotal)/100)) - // Calculate spacing nameAndPriceWidth := lipgloss.Width(nameText) + lipgloss.Width(individualPriceText) quantityWidth := lipgloss.Width(quantityText) totalPriceWidth := lipgloss.Width(totalPriceText) - spacing := boxWidth - nameAndPriceWidth - quantityWidth - totalPriceWidth - 8 // 8 for padding and gaps + spacing := boxWidth - nameAndPriceWidth - quantityWidth - totalPriceWidth - 8 if spacing < 1 { spacing = 1 @@ -78,7 +93,6 @@ func (m Model) BuildCartView() string { boxContent := contentLine1 + "\n" + contentLine2 - // Create box for this item - highlight if it's the selected item var itemBox lipgloss.Style if idx == m.CartCursor { itemBox = lipgloss.NewStyle(). @@ -97,22 +111,43 @@ func (m Model) BuildCartView() string { Foreground(lipgloss.Color("#FFFFFF")) } - // Center the box within the container centered := lipgloss.NewStyle(). Width(m.widthContent). Align(lipgloss.Center) cartItems += centered.Render(itemBox.Render(boxContent)) - // Add spacing between items if idx < len(itemSlice)-1 { cartItems += "\n" } } - return cartItems } +func (m Model) CartView() string { + if !m.cartVPReady { + m = m.updateCartViewport() + } + content := m.generateCartContent() + m.cartVP.SetContent(content) + if len(m.Cart) > 0 { + itemHeight := 5 + targetY := m.CartCursor * itemHeight + if targetY < m.cartVP.YOffset { + m.cartVP.SetYOffset(targetY) + } + if targetY+itemHeight > m.cartVP.YOffset+m.cartVP.Height { + m.cartVP.SetYOffset(targetY - m.cartVP.Height + itemHeight) + } + } + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(m.cartVP.View()), + lipgloss.Center, lipgloss.Center, + m.cartVP.View(), + ) +} + func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if !ok { @@ -122,32 +157,11 @@ func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) { case "up", "k": if m.CartCursor > 0 { m.CartCursor-- - itemHeight := 5 - targetLine := m.CartCursor * itemHeight - if targetLine < m.ScrollOffset { - m.ScrollOffset = targetLine - } } case "down", "j": if m.CartCursor < len(m.Cart)-1 { m.CartCursor++ - itemHeight := 5 - targetLineEnd := (m.CartCursor * itemHeight) + itemHeight - viewportHeight := m.heightContainer - 9 - if viewportHeight < 6 { - viewportHeight = 6 - } - if targetLineEnd > m.ScrollOffset+viewportHeight { - m.ScrollOffset = targetLineEnd - viewportHeight - } } - case "pgup", "ctrl+u": - m.ScrollOffset -= 3 - if m.ScrollOffset < 0 { - m.ScrollOffset = 0 - } - case "pgdown", "ctrl+d": - m.ScrollOffset += 3 case "+", "=": cartItems := m.GetCartItemsSlice() if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { @@ -174,6 +188,7 @@ func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) { case "p", "enter": if len(m.Cart) > 0 { m = m.SwitchPage(shippingPage) + m = m.updateShippingViewport() return m, m.fetchAddressesCmd() } } diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index e05def0..6ea6900 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -5,8 +5,9 @@ import ( "terminalShop/pkg/models" - "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) // RenderConfirmation renders the order confirmation view @@ -73,21 +74,58 @@ func (m Model) RenderConfirmation() string { ) } +func (m Model) updateConfirmViewport() Model { + headerH := lipgloss.Height(m.BuildHeader()) + breadH := lipgloss.Height(m.BuildBreadcrumbs()) + footerH := lipgloss.Height(m.BuildFooter()) + availH := m.heightContainer - headerH - footerH - breadH + if availH < 1 { + availH = 1 + } + if !m.confirmVPReady { + m.confirmVP = viewport.New(m.widthContent, availH) + m.confirmVP.KeyMap = viewport.DefaultKeyMap() + m.confirmVPReady = true + } else { + m.confirmVP.Width = m.widthContent + m.confirmVP.Height = availH + } + return m +} + +func (m Model) ConfirmView() string { + if !m.confirmVPReady { + m = m.updateConfirmViewport() + } + m.confirmVP.SetContent(m.RenderConfirmation()) + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(m.confirmVP.View()), + lipgloss.Center, lipgloss.Center, + m.confirmVP.View(), + ) +} + func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) { - keyMsg, ok := msg.(tea.KeyMsg) - if !ok { - return m, nil + var cmd tea.Cmd + if m.confirmVPReady { + m.confirmVP, cmd = m.confirmVP.Update(msg) } - if keyMsg.String() != "esc" && keyMsg.String() != "s" { - return m, nil + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, cmd + } + + if keyMsg.String() != "esc" && keyMsg.String() != "s" { + return m, cmd } m = m.SwitchPage(shopPage) m.ShippingInfo = nil m.CheckingOut = false m.Cart = make(map[uint]*models.CartItem) - m.CartCursor = 0 + m.CartCursor = 0 m = m.resetPageState() return m, func() tea.Msg { if m.APIClient != nil { diff --git a/pkg/tui/menu_test.go b/pkg/tui/menu_test.go index e2d9b00..794cc94 100644 --- a/pkg/tui/menu_test.go +++ b/pkg/tui/menu_test.go @@ -1,96 +1,20 @@ package tui import ( - "terminalShop/pkg/api" - "terminalShop/pkg/models" "testing" - - gossh "golang.org/x/crypto/ssh" ) func TestModel_BuildMenuView(t *testing.T) { - type fields struct { - User *models.User - IsNewUser bool - SSHPublicKey gossh.PublicKey - AccessToken string - UsernameInput string - Username string - Coffees []models.Coffee - Cursor int - Cart map[uint]*models.CartItem - CartCursor int - AccountCursor int - ScrollOffset int - currentPage page - viewportWidth int - viewportHeight int - widthContainer int - heightContainer int - widthContent int - size termSize - resizeSeq int - pendingWidth int - pendingHeight int - Loading bool - ErrorMsg string - APIClient *api.Client - ShippingForm *ShippingFormState - PaymentForm *PaymentFormState - ShippingInfo *models.Address - SavedAddresses []models.Address - ShippingView int - AddressCursor int - StripeKey string - Orders []models.Order - OrdersLoaded bool - } tests := []struct { - name string - fields fields - want string + name string + m Model + want string }{ // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := Model{ - User: tt.fields.User, - IsNewUser: tt.fields.IsNewUser, - SSHPublicKey: tt.fields.SSHPublicKey, - AccessToken: tt.fields.AccessToken, - UsernameInput: tt.fields.UsernameInput, - Username: tt.fields.Username, - Coffees: tt.fields.Coffees, - Cursor: tt.fields.Cursor, - Cart: tt.fields.Cart, - CartCursor: tt.fields.CartCursor, - AccountCursor: tt.fields.AccountCursor, - ScrollOffset: tt.fields.ScrollOffset, - currentPage: tt.fields.currentPage, - viewportWidth: tt.fields.viewportWidth, - viewportHeight: tt.fields.viewportHeight, - widthContainer: tt.fields.widthContainer, - heightContainer: tt.fields.heightContainer, - widthContent: tt.fields.widthContent, - size: tt.fields.size, - resizeSeq: tt.fields.resizeSeq, - pendingWidth: tt.fields.pendingWidth, - pendingHeight: tt.fields.pendingHeight, - Loading: tt.fields.Loading, - ErrorMsg: tt.fields.ErrorMsg, - APIClient: tt.fields.APIClient, - ShippingForm: tt.fields.ShippingForm, - PaymentForm: tt.fields.PaymentForm, - ShippingInfo: tt.fields.ShippingInfo, - SavedAddresses: tt.fields.SavedAddresses, - ShippingView: tt.fields.ShippingView, - AddressCursor: tt.fields.AddressCursor, - StripeKey: tt.fields.StripeKey, - Orders: tt.fields.Orders, - OrdersLoaded: tt.fields.OrdersLoaded, - } - if got := m.BuildMenuView(); got != tt.want { + if got := tt.m.BuildMenuView(); got != tt.want { t.Errorf("Model.BuildMenuView() = %v, want %v", got, tt.want) } }) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 0e9ad04..07c7fb0 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -1,16 +1,16 @@ package tui import ( - "encoding/json" "fmt" "log" "math" - "net/http" - "os" - "strings" "time" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/stripe/stripe-go/v78" + stripetoken "github.com/stripe/stripe-go/v78/token" "terminalShop/pkg/api" "terminalShop/pkg/models" @@ -56,13 +56,22 @@ type Model struct { SSHPublicKeyStr string AccessToken string AuthFingerprintKey string // shared secret for /auth/token refresh + StripePublicKey string // Shop state Username string Coffees []models.Coffee - Cursor int - Cart map[uint]*models.CartItem // maps CoffeeID to cart item - CartCursor int // cursor position in cart view + shop shopState + cartVP viewport.Model + cartVPReady bool + shippingVP viewport.Model + shippingVPReady bool + paymentVP viewport.Model + paymentVPReady bool + confirmVP viewport.Model + confirmVPReady bool + Cart map[uint]*models.CartItem + CartCursor int // cursor position in cart view AccountCursor int currentPage page // currently active screen switched bool // true when the page just changed (for state resets) @@ -93,17 +102,18 @@ type Model struct { APIClient *api.Client // Checkout state - ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step) - PaymentForm *PaymentFormState // Payment form state (nil when not in payment step) - ShippingInfo *models.Address // Selected shipping address for current order - SavedAddresses []models.Address // Saved addresses from database - ShippingView int - AddressCursor int - StripeKey string // Stripe publishable key for client-side tokenization - SavedCards []models.Card // Saved cards from database - SelectedCard *models.Card // Card selected from saved list (nil = new card) - CardCursor int // cursor position in the card list - PaymentView int // 0=card list, 1=new card form + ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step) + PaymentForm *PaymentFormState // Payment form state (nil when not in payment step) + ShippingInfo *models.Address // Selected shipping address for current order + SavedAddresses []models.Address // Saved addresses from database + ShippingView int + AddressCursor int + SavedCards []models.Card // Saved cards from database + SelectedCard *models.Card // Card selected from saved list (nil = new card) + CardCursor int // cursor position in the card list + PaymentView int // 0=card list, 1=new card form, 2 = browser payment + CollectURL *string // URL for browser-based card entry + CollectCardCount int // card count when browser polling started // Order history Orders []models.Order @@ -122,6 +132,20 @@ type Model struct { } +type shopState struct { + selected int + menuViewport viewport.Model + detailViewport viewport.Model + viewportsReady bool +} + +var modifiedKeyMap = viewport.KeyMap{ + PageDown: key.NewBinding(key.WithKeys("pgdown")), + PageUp: key.NewBinding(key.WithKeys("pgup")), + HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u")), + HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d")), +} + // SwitchPage changed the active page and marks that a switch happened func (m Model) SwitchPage(p page) Model { m.currentPage = p @@ -238,15 +262,6 @@ func (m Model) resetPageState() Model { return m } -type StripeTokenMsg struct { - TokenID string - Last4 string -} - -type StripeTokenErrMsg struct { - Err error -} - type CheckoutResultMsg struct { OrderID uint Total int @@ -291,7 +306,7 @@ func (m Model) scheduleTokenRefreshCmd() tea.Cmd { // refreshTokenCmd calls the /auth/token endpoint to get a fresh JWT. func (m Model) refreshTokenCmd() tea.Cmd { return func() tea.Msg { - if m.APIClient == nil || m.Fingerprint == "" { + if m.APIClient == nil || m.Fingerprint == "" { return TokenRefreshedMsg{Err: fmt.Errorf("not authenticated, cannot refresh token")} } @@ -458,62 +473,6 @@ func (m Model) checkoutWithSavedCard() tea.Cmd { } } -func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd { - return func() tea.Msg { - // Use the Stripe secret key for server-side tokenization. - // This is a terminal app running over SSH — there is no browser, - // so the publishable key / Stripe.js approach does not work. - // The secret key is allowed to create tokens with raw card data. - secretKey := os.Getenv("STRIPE_SECRET_KEY") - if secretKey == "" { - // Fall back to the publishable key (will fail on most accounts) - secretKey = m.StripeKey - } - - data := fmt.Sprintf( - "card[number]=%s&card[exp_month]=%s&card[exp_year]=%s&card[cvc]=%s", - card.CardNumber, card.ExpiryMonth, card.ExpiryYear, card.CVC, - ) - - req, err := http.NewRequest("POST", "https://api.stripe.com/v1/tokens", strings.NewReader(data)) - if err != nil { - return StripeTokenErrMsg{Err: err} - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", "Bearer "+secretKey) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return StripeTokenErrMsg{Err: err} - } - defer resp.Body.Close() - - var result struct { - ID string `json:"id"` - Card struct { - Last4 string `json:"last4"` - Brand string `json:"brand"` - } `json:"card"` - Error *struct { - Message string `json:"message"` - } `json:"error"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return StripeTokenErrMsg{Err: err} - } - - if result.Error != nil { - return StripeTokenErrMsg{Err: fmt.Errorf("%s", result.Error.Message)} - } - - return StripeTokenMsg{ - TokenID: result.ID, - Last4: result.Card.Last4, - } - } -} - func (m *Model) syncCartItemCmd(coffeeID uint, quantity int) tea.Cmd { if m.APIClient == nil || m.User == nil { return nil @@ -532,24 +491,34 @@ func (m *Model) syncCartItemCmd(coffeeID uint, quantity int) tea.Cmd { } } -// saveCardAndConvert saves the tokenized card, sets address and card on the -// server cart, then converts the cart to an order -func (m Model) saveCardAndConvert(tok StripeTokenMsg) tea.Cmd { +// saveCardAndConvert tokenizes the card locally using the Stripe publishable +// key (raw card data never reaches the backend), saves the token, then converts the cart +func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd { return func() tea.Msg { if m.APIClient == nil { return CheckoutResultMsg{Err: fmt.Errorf("API client not available")} } - // 1. Save the card - card, err := m.APIClient.SaveCard(tok.TokenID) + stripe.Key = m.StripePublicKey + tok, err := stripetoken.New(&stripe.TokenParams{ + Card: &stripe.CardParams{ + Name: stripe.String(form.CardName), + Number: stripe.String(form.CardNumber), + ExpMonth: stripe.String(form.ExpiryMonth), + ExpYear: stripe.String(form.ExpiryYear), + CVC: stripe.String(form.CVC), + AddressZip: stripe.String(form.BillingZip), + }, + }) if err != nil { - // Card may already exist — try fetching existing cards - cards, listErr := m.APIClient.GetCards() - if listErr != nil || len(cards) == 0 { - return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)} + if stripeErr, ok := err.(*stripe.Error); ok { + return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)} } - // Use the most recent card - card = &cards[0] + return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)} + } + card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID}) + if err != nil { + return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)} } // 2. Set the shipping address on the cart @@ -702,7 +671,6 @@ func NewModel(username string) Model { Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.", }, }, - Cursor: 0, Cart: make(map[uint]*models.CartItem), CartCursor: 0, AccountCursor: 0, @@ -710,7 +678,6 @@ func NewModel(username string) Model { OrderViewState: 0, Loading: true, APIClient: api.NewClient("http://localhost:8080", ""), - StripeKey: os.Getenv("STRIPE_PUBLIC_KEY"), FAQs: LoadFaqs(), } m.updateLayout(120, 30) @@ -718,11 +685,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) Model { +func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model { m := NewModel("") m.Fingerprint = fingerprint m.SSHPublicKeyStr = pubKeyStr m.AuthFingerprintKey = clientSecret + m.StripePublicKey = stripePublicKey m.currentPage = splashPage if apiURL != "" { m.APIClient = api.NewClient(apiURL, "") @@ -731,3 +699,36 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien } return m } + +func newViewport(w, h int) viewport.Model { + return viewport.New(w, h) +} + +func (m Model) collectCardCmd() tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil { + return CheckoutResultMsg{Err: fmt.Errorf("API client not available")} + } + url, err := m.APIClient.CollectCard() + if err != nil { + return CheckoutResultMsg{Err: fmt.Errorf("failed to generate payment link: %w", err)} + } + return PollPaymentInitMsg{URL: url} + } +} + +func (m Model) pollCardsCmd(cardCount int) tea.Cmd { + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { + if m.APIClient == nil { + return PollPaymentStatusMsg{CardCount: cardCount} + } + cards, err := m.APIClient.GetCards() + if err != nil { + return PollPaymentStatusMsg{CardCount: cardCount} + } + if len(cards) > cardCount { + return PollPaymentCompleteMsg{Cards: cards} + } + return PollPaymentStatusMsg{CardCount: cardCount} + }) +} diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go index dced1ea..206fbc9 100644 --- a/pkg/tui/model_test.go +++ b/pkg/tui/model_test.go @@ -1,100 +1,30 @@ package tui import ( - "terminalShop/pkg/api" - "terminalShop/pkg/models" "testing" - - gossh "golang.org/x/crypto/ssh" ) func TestModel_updateLayout(t *testing.T) { - type fields struct { - User *models.User - IsNewUser bool - SSHPublicKey gossh.PublicKey - AccessToken string - UsernameInput string - Username string - Coffees []models.Coffee - Cursor int - Cart map[uint]*models.CartItem - CartCursor int - AccountCursor int - ScrollOffset int - currentPage page - viewportWidth int - viewportHeight int - widthContainer int - heightContainer int - widthContent int - size termSize - resizeSeq int - pendingWidth int - pendingHeight int - Loading bool - ErrorMsg string - APIClient *api.Client - ShippingForm *ShippingFormState - PaymentForm *PaymentFormState - ShippingInfo *models.Address - SavedAddresses []models.Address - ShippingView int - AddressCursor int - StripeKey string - Orders []models.Order - OrdersLoaded bool - } - type args struct { - width int - height int - } tests := []struct { name string - fields fields - args args + width int + height int }{ - // TODO: Add test cases. + {"large terminal", 120, 40}, + {"medium terminal", 70, 25}, + {"small terminal", 45, 20}, + {"undersized", 15, 8}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := &Model{ - User: tt.fields.User, - IsNewUser: tt.fields.IsNewUser, - SSHPublicKey: tt.fields.SSHPublicKey, - AccessToken: tt.fields.AccessToken, - UsernameInput: tt.fields.UsernameInput, - Username: tt.fields.Username, - Coffees: tt.fields.Coffees, - Cursor: tt.fields.Cursor, - Cart: tt.fields.Cart, - CartCursor: tt.fields.CartCursor, - AccountCursor: tt.fields.AccountCursor, - ScrollOffset: tt.fields.ScrollOffset, - currentPage: tt.fields.currentPage, - viewportWidth: tt.fields.viewportWidth, - viewportHeight: tt.fields.viewportHeight, - widthContainer: tt.fields.widthContainer, - heightContainer: tt.fields.heightContainer, - widthContent: tt.fields.widthContent, - size: tt.fields.size, - resizeSeq: tt.fields.resizeSeq, - pendingWidth: tt.fields.pendingWidth, - pendingHeight: tt.fields.pendingHeight, - Loading: tt.fields.Loading, - ErrorMsg: tt.fields.ErrorMsg, - APIClient: tt.fields.APIClient, - ShippingForm: tt.fields.ShippingForm, - PaymentForm: tt.fields.PaymentForm, - ShippingInfo: tt.fields.ShippingInfo, - SavedAddresses: tt.fields.SavedAddresses, - ShippingView: tt.fields.ShippingView, - AddressCursor: tt.fields.AddressCursor, - StripeKey: tt.fields.StripeKey, - Orders: tt.fields.Orders, - OrdersLoaded: tt.fields.OrdersLoaded, + m := NewModel("") + m.updateLayout(tt.width, tt.height) + if m.viewportWidth != tt.width { + t.Errorf("viewportWidth = %d, want %d", m.viewportWidth, tt.width) + } + if m.viewportHeight != tt.height { + t.Errorf("viewportHeight = %d, want %d", m.viewportHeight, tt.height) } - m.updateLayout(tt.args.width, tt.args.height) }) } } diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 1fef2c0..67aa8e1 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -4,11 +4,13 @@ import ( "fmt" "strings" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "terminalShop/pkg/models" + "terminalShop/pkg/tui/qrfefe" "terminalShop/pkg/validate" ) @@ -42,6 +44,23 @@ type PaymentFormErrorMsg struct { Message string } +// PollPaymentInitMsg is sent when the collect endpoint returns a URL +type PollPaymentInitMsg struct { + URL string +} + +// PollPaymentStatusMsg is sent each polling tick while waiting for the browser +// card entry to complete. +type PollPaymentStatusMsg struct { + CardCount int +} + +// PollPaymentCompleteMsg is sent when the card count increases, meaning the +// webhook saved the new card +type PollPaymentCompleteMsg struct { + Cards []models.Card +} + // buildPaymentForm creates the huh form bound to state's fields. // No field-level validators so Tab/Enter move freely. func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form { @@ -244,6 +263,72 @@ func (m Model) RenderPaymentForm(state *PaymentFormState) string { ) } +func (m Model) updatePaymentViewport() Model { + headerH := lipgloss.Height(m.BuildHeader()) + breadH := lipgloss.Height(m.BuildBreadcrumbs()) + footerH := lipgloss.Height(m.BuildFooter()) + availH := m.heightContainer - headerH - footerH - breadH + if availH < 1 { + availH = 1 + } + if !m.paymentVPReady { + m.paymentVP = viewport.New(m.widthContent, availH) + m.paymentVP.KeyMap = viewport.KeyMap{} + m.paymentVPReady = true + } else { + m.paymentVP.Width = m.widthContent + m.paymentVP.Height = availH + } + return m +} + +func (m Model) PaymentPageView() string { + if !m.paymentVPReady { + m = m.updatePaymentViewport() + } + var content string + if m.CheckingOut { + content = " submitting order..." + } else if m.PaymentView == 2 { + // Bypass viewport — it strips the raw ANSI codes qrfefe emits. + return lipgloss.Place( + m.widthContainer, + m.paymentVP.Height, + lipgloss.Center, lipgloss.Center, + m.renderPaymentHttpsView(), + ) + } else if m.PaymentView == 0 && m.PaymentForm == nil { + content = m.RenderCardList() + m.paymentVP.SetContent(content) + itemHeight := 3 + targetY := m.CardCursor * itemHeight + if targetY < m.paymentVP.YOffset { + m.paymentVP.SetYOffset(targetY) + } + if targetY+itemHeight > m.paymentVP.YOffset+m.paymentVP.Height { + m.paymentVP.SetYOffset(targetY - m.paymentVP.Height + itemHeight + 1) + } + if m.CardCursor == len(m.SavedCards) { + m.paymentVP.GotoBottom() + } + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(m.paymentVP.View()), + lipgloss.Center, lipgloss.Center, + m.paymentVP.View(), + ) + } else if m.PaymentForm != nil { + content = m.RenderPaymentForm(m.PaymentForm) + } + m.paymentVP.SetContent(content) + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(m.paymentVP.View()), + lipgloss.Center, lipgloss.Center, + m.paymentVP.View(), + ) +} + func (m Model) RenderCardList() string { titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0) activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) @@ -270,14 +355,21 @@ func (m Model) RenderCardList() string { lines = append(lines, "") } - addCursor := " " - addStyle := inactiveStyle + sshCursor := " " + sshStyle := inactiveStyle if m.CardCursor == len(m.SavedCards) { - addCursor = "> " - addStyle = activeStyle + sshCursor = "> " + sshStyle = activeStyle } + lines = append(lines, sshCursor+sshStyle.Render("+ add card via ssh")) - lines = append(lines, addCursor+addStyle.Render("+ Add new card")) + httpsCursor := " " + httpsStyle := inactiveStyle + if m.CardCursor == len(m.SavedCards)+1 { + httpsCursor = "> " + httpsStyle = activeStyle + } + lines = append(lines, httpsCursor+httpsStyle.Render("+ add payment via browser")) parts := []string{title} parts = append(parts, strings.Join(lines, "\n")) @@ -285,18 +377,38 @@ func (m Model) RenderCardList() string { return lipgloss.JoinVertical(lipgloss.Left, parts...) } +func (m Model) renderPaymentHttpsView() string { + baseStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) + accentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4682B4")) + + if m.CollectURL == nil { + return baseStyle.Render(" generating payment link...") + } + + qr, qrSize, err := qrfefe.Generate(0, *m.CollectURL) + if err != nil || qrSize > m.widthContent { + // QR too wide for the terminal — just show the URL. + return baseStyle.Render("open in browser to add payment information:") + "\n\n" + + accentStyle.Render(*m.CollectURL) + "\n" + } + + // Concatenate manually — lipgloss layout functions (JoinVertical with Center) + // measure string widths and add padding that corrupts qrfefe's raw ANSI codes. + return qr + + baseStyle.Render("scan or visit to add payment information") + "\n" + + accentStyle.Render(*m.CollectURL) + "\n" +} + // PaymentUpdate handles all messages while on the payment page. // Moved from model.go Update() lines 429-578. func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case CardsMsg: - if msg.Err != nil || len(msg.Cards) == 0 { + if msg.Err != nil { m.SavedCards = nil - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, m.PaymentForm.form.Init() + } else { + m.SavedCards = msg.Cards } - m.SavedCards = msg.Cards m.PaymentView = 0 m.CardCursor = 0 m.PaymentForm = nil @@ -304,15 +416,12 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { case PaymentFormCompleteMsg: if m.PaymentForm != nil { - return m, m.tokenizeCard(msg) + m.PaymentForm.submitting = true + m.CheckingOut = true + return m, m.saveCardAndConvert(msg) } return m, nil - case StripeTokenMsg: - m.PaymentForm = nil - m.CheckingOut = true - return m, m.saveCardAndConvert(msg) - case CheckoutResultMsg: m.CheckingOut = false if msg.Err != nil { @@ -322,6 +431,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.PaymentForm = nil return m, nil } + // Re-open the form so the user can retry with corrected details. m.PaymentView = 1 m.PaymentForm = m.InitPaymentForm() return m, m.PaymentForm.form.Init() @@ -333,19 +443,32 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.ShippingInfo = nil return m, nil - case StripeTokenErrMsg: - if m.PaymentForm != nil { - m.PaymentForm.submitting = false - m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err) - m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) - return m, m.PaymentForm.form.Init() - } - m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err) - return m, nil - case PaymentFormErrorMsg: m.ErrorMsg = msg.Message return m, nil + + case PollPaymentInitMsg: + url := msg.URL + m.CollectURL = &url + return m, m.pollCardsCmd(m.CollectCardCount) + + case PollPaymentStatusMsg: + if m.PaymentView != 2 { + return m, nil // user navigated away, stop polling + } + return m, m.pollCardsCmd(msg.CardCount) + + case PollPaymentCompleteMsg: + if m.PaymentView != 2 { + return m, nil + } + m.SavedCards = msg.Cards + m.PaymentView = 0 + m.CollectURL = nil + selected := m.SavedCards[len(m.SavedCards)-1] + m.SelectedCard = &selected + m.CheckingOut = true + return m, m.checkoutWithSavedCard() } // Card list navigation @@ -364,9 +487,10 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.CardCursor-- } case "down", "j": - if m.CardCursor < len(m.SavedCards) { + if m.CardCursor < len(m.SavedCards)+1 { m.CardCursor++ } + case "enter": if m.CardCursor < len(m.SavedCards) { selected := m.SavedCards[m.CardCursor] @@ -374,9 +498,17 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.CheckingOut = true return m, m.checkoutWithSavedCard() } - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, m.PaymentForm.form.Init() + if m.CardCursor == len(m.SavedCards) { + m.PaymentView = 1 + m.PaymentForm = m.InitPaymentForm() + return m, m.PaymentForm.form.Init() + } + // Browser payment + m.PaymentView = 2 + m.CollectURL = nil + m.CollectCardCount = len(m.SavedCards) + return m, m.collectCardCmd() + case "d", "x": if m.CardCursor < len(m.SavedCards) { card := m.SavedCards[m.CardCursor] @@ -395,6 +527,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { return m, nil } + // HTTPS/browser view avigation + if m.PaymentView == 2 { + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" { + m.PaymentView = 0 + m.CollectURL = nil + return m, nil + } + return m, nil + } + // Payment form navigation if m.PaymentForm != nil { if keyMsg, ok := msg.(tea.KeyMsg); ok { diff --git a/pkg/tui/qrfefe/qr.go b/pkg/tui/qrfefe/qr.go new file mode 100644 index 0000000..7f5388b --- /dev/null +++ b/pkg/tui/qrfefe/qr.go @@ -0,0 +1,58 @@ +package qrfefe + +import ( + sgr "github.com/foize/go.sgr" + + "rsc.io/qr" +) + +var tops_bottoms = []rune{' ', '▀', '▄', '█'} + +func Generate(size int, text string) (string, int, error) { + return generate(qr.L, text) +} + +func generate(level qr.Level, text string) (string, int, error) { + code, err := qr.Encode(text, qr.Level(level)) + if err != nil { + return "", 0, err + } + + qrRunes := make([]rune, 0) + + for y := 0; y < code.Size-1; y+= 2 { + qrRunes = append(qrRunes, []rune(sgr.FgWhite+sgr.BgBlack)...) + + for x := 0; x < code.Size; x += 1 { + var num int8 + if code.Black(x, y) { // top pixel black + num += 1 + } + + if code.Black(x, y+1) { // bottom pixel black + num += 2 + } + qrRunes = append(qrRunes, tops_bottoms[num]) + } + + qrRunes = append(qrRunes, []rune(sgr.Reset)...) + qrRunes = append(qrRunes, '\n') + } + + addWhiteRow(&qrRunes, code.Size+4) + + return string(qrRunes), code.Size, nil +} + +func addWhiteRow(qrRunes *[]rune, width int) { + if qrRunes == nil { + return + } + + *qrRunes = append(*qrRunes, []rune(sgr.FgWhite+sgr.BgBlack)...) + for i := 1; i < width-3; i++ { + *qrRunes = append(*qrRunes, '▀') + } + *qrRunes = append(*qrRunes, []rune(sgr.Reset)...) + *qrRunes = append(*qrRunes, '\n') +} diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go index 5863d58..aa4abda 100644 --- a/pkg/tui/shipping_form.go +++ b/pkg/tui/shipping_form.go @@ -6,6 +6,7 @@ import ( "terminalShop/pkg/models" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -262,6 +263,54 @@ func (m Model) RenderShippingForm(state *ShippingFormState) string { ) } +func (m Model) updateShippingViewport() Model { + headerH := lipgloss.Height(m.BuildHeader()) + breadH := lipgloss.Height(m.BuildBreadcrumbs()) + footerH := lipgloss.Height(m.BuildFooter()) + availH := m.heightContainer - headerH - footerH - breadH + if availH < 1 { + availH = 1 + } + if !m.shippingVPReady { + m.shippingVP = viewport.New(m.widthContent, availH) + m.shippingVP.KeyMap = viewport.KeyMap{} + m.shippingVPReady = true + } else { + m.shippingVP.Width = m.widthContent + m.shippingVP.Height = availH + } + return m +} + +func (m Model) ShippingPageView() string { + if !m.shippingVPReady { + m = m.updateShippingViewport() + } + if m.ShippingView == 0 && m.ShippingForm == nil { + content := m.RenderAddressList() + m.shippingVP.SetContent(content) + itemHeight := 4 + targetY := m.AddressCursor * itemHeight + if targetY < m.shippingVP.YOffset { + m.shippingVP.SetYOffset(targetY) + } + if targetY+itemHeight > m.shippingVP.YOffset+m.shippingVP.Height { + m.shippingVP.SetYOffset(targetY - m.shippingVP.Height + itemHeight + 1) + } + if m.AddressCursor == len(m.SavedAddresses) { + m.shippingVP.GotoBottom() + } + } else if m.ShippingForm != nil { + m.shippingVP.SetContent(m.RenderShippingForm(m.ShippingForm)) + } + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(m.shippingVP.View()), + lipgloss.Center, lipgloss.Center, + m.shippingVP.View(), + ) +} + func (m Model) RenderAddressList() string { titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0) activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go index 611d36c..62b7a11 100644 --- a/pkg/tui/shop.go +++ b/pkg/tui/shop.go @@ -2,162 +2,232 @@ package tui import ( "fmt" - - "terminalShop/pkg/models" + "strings" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/bubbletea" + + "terminalShop/pkg/models" ) -func (m Model) BuildShopView() string { - leftWidth := 18 - rightWidth := m.widthContent - leftWidth - 2 // 2 for gap - - // On small/medium, items are centered and span the full container width. - // On large, items are left-aligned in the narrow left column. - itemWidth := leftWidth - 2 - itemAlign := lipgloss.Left - if m.size < large { - itemWidth = m.widthContainer - itemAlign = lipgloss.Center - } - - // Build the left panel (coffee list) - leftPanel := "" - for i, coffee := range m.Coffees { - if m.Cursor == i { - style := lipgloss.NewStyle(). - Background(lipgloss.Color(coffee.Color)). - Foreground(lipgloss.Color("#FFFFFF")). - Padding(0, 1). - Width(itemWidth). - Align(itemAlign) - leftPanel += style.Render(coffee.Name) + "\n" - } else { - style := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#AAAAAA")). - Padding(0, 1). - Width(itemWidth). - Align(itemAlign) - leftPanel += style.Render(coffee.Name) + "\n" - } - } - - var leftContainer lipgloss.Style - if m.size < large { - leftContainer = lipgloss.NewStyle().Width(m.widthContainer) - } else { - leftContainer = lipgloss.NewStyle().Width(leftWidth) - } - - // Build the detail view (right panel) - detailView := "" - if m.Cursor >= 0 && m.Cursor < len(m.Coffees) { - selectedCoffee := m.Coffees[m.Cursor] - - nameStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")) - - infoStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#AAAAAA")) - - priceStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(selectedCoffee.Color)). - Bold(true) - - // On small terminals, use full content width for description - descWidth := rightWidth - 2 +func (m Model) calculateShopWidths() (int, int) { + menuWidth := 14 + if len(m.Coffees) == 0 { if m.size < large { - descWidth = m.widthContent - 2 + return m.widthContent, m.widthContent } - - descStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Width(descWidth) - - quantityStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true) - - // Get current quantity (look up by CoffeeID) - quantity := 0 - if item, exists := m.Cart[selectedCoffee.ID]; exists { - quantity = item.Quantity + 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 + } + if m.size < large { + menuWidth = m.widthContent + } + detailWidth := m.widthContent - menuWidth + if m.size < large { + detailWidth = m.widthContent + } + return menuWidth, detailWidth +} - detailView += nameStyle.Render(selectedCoffee.Name) + "\n" - detailView += infoStyle.Render(fmt.Sprintf("%s | %doz | %s", - selectedCoffee.RoastType, - selectedCoffee.Ounces, - selectedCoffee.BeanType)) + "\n\n" - detailView += priceStyle.Render(fmt.Sprintf("$%.2f", float64(selectedCoffee.Price)/100)) + "\n\n" - detailView += descStyle.Render(selectedCoffee.Description) + "\n\n" - detailView += quantityStyle.Render(fmt.Sprintf("- %d +", quantity)) + "\n" +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 } - detailContainer := lipgloss.NewStyle(). - Width(rightWidth) + menuWidth, detailWidth := m.calculateShopWidths() - // Responsive layout: side-by-side on large, stacked on small/medium + 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.Width = menuWidth + m.shop.menuViewport.Height = availableHeight + m.shop.detailViewport.Width = detailWidth + m.shop.detailViewport.Height = availableHeight + } + return m +} + +func (m Model) getShopMenuContent() string { + menuWidth, _ := m.calculateShopWidths() + + selectedColor := lipgloss.Color("#4682B4") + if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) { + if c := m.Coffees[m.shop.selected].Color; c != "" { + selectedColor = lipgloss.Color(c) + } + } + + var normal, highlighted lipgloss.Style if m.size < large { - return lipgloss.JoinVertical( - lipgloss.Left, - leftContainer.Render(leftPanel), - detailContainer.Width(m.widthContainer).Render(detailView), + menuWidth = m.widthContent + normal = lipgloss.NewStyle(). + Width(menuWidth - 1).Align(lipgloss.Center). + Foreground(lipgloss.Color("#AAAAAA")) + highlighted = lipgloss.NewStyle(). + Width(menuWidth - 1).Align(lipgloss.Center). + Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + } else { + normal = lipgloss.NewStyle(). + Width(menuWidth + 2).Padding(0, 1). + Foreground(lipgloss.Color("#AAAAAA")) + highlighted = lipgloss.NewStyle(). + Width(menuWidth + 2).Padding(0, 1). + Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + } + + var b strings.Builder + for i, c := range m.Coffees { + if i == m.shop.selected { + b.WriteString(highlighted.Render(c.Name) + "\n") + } else { + b.WriteString(normal.Render(c.Name) + "\n") + } + } + return lipgloss.NewStyle().Padding(0, 1).Render(b.String()) +} + +func (m Model) getShopDetailContent() string { + if len(m.Coffees) == 0 { + return "" + } + 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, + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Render(coffee.Name), + lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render( + fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType), + ), + "", + lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render( + fmt.Sprintf("$%.2f", float64(coffee.Price)/100), + ), + "", + lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Width(detailWidth).Render(coffee.Description), + "", + fmt.Sprintf("- %d +", quantity), + ) + 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()) + + if m.size < large { + return lipgloss.JoinVertical(lipgloss.Top, + m.shop.menuViewport.View(), + m.shop.detailViewport.View(), ) } - - return lipgloss.JoinHorizontal( - lipgloss.Top, - leftContainer.Render(leftPanel), + return lipgloss.JoinHorizontal(lipgloss.Top, + m.shop.menuViewport.View(), " ", - detailContainer.Render(detailView), + m.shop.detailViewport.View(), ) } func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) { - keyMsg, ok := msg.(tea.KeyMsg) - if !ok { + var cmd tea.Cmd + var cmds []tea.Cmd + + if len(m.Coffees) == 0 { return m, nil } - switch keyMsg.String() { - case "up", "k": - if m.Cursor > 0 { - m.Cursor-- - } - case "down", "j": - if m.Cursor < len(m.Coffees)-1 { - m.Cursor++ - } - case "+", "=": - coffeeID := m.Coffees[m.Cursor].ID - if item, exists := m.Cart[coffeeID]; exists { - item.Quantity++ - } else { - m.Cart[coffeeID] = &models.CartItem{ - CoffeeID: coffeeID, - Coffee: m.Coffees[m.Cursor], - Quantity: 1, + + switch msg := msg.(type) { + case tea.KeyMsg: + coffee := m.Coffees[m.shop.selected] + switch msg.String() { + case "up", "k": + m = m.shopMoveSelected(true) + case "down", "j": + m = m.shopMoveSelected(false) + case "+", "=": + coffeeID := coffee.ID + if item, exists := m.Cart[coffeeID]; exists { + item.Quantity++ + } else { + m.Cart[coffeeID] = &models.CartItem{CoffeeID: coffeeID, Coffee: coffee, Quantity: 1} } - } - return m, m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity) - case "-", "_": - coffeeID := m.Coffees[m.Cursor].ID - if item, exists := m.Cart[coffeeID]; exists { - item.Quantity-- - newQty := item.Quantity - if item.Quantity <= 0 { - delete(m.Cart, coffeeID) - if len(m.Cart) == 0 { - m.CardCursor = 0 - } else if m.CartCursor >= len(m.Cart) { - m.CartCursor = len(m.Cart) - 1 + return m, m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity) + case "-", "_": + coffeeID := coffee.ID + if item, exists := m.Cart[coffeeID]; exists { + item.Quantity-- + newQty := item.Quantity + if item.Quantity <= 0 { + delete(m.Cart, coffeeID) + newQty = 0 } - newQty = 0 + return m, m.syncCartItemCmd(coffeeID, newQty) } - return m, m.syncCartItemCmd(coffeeID, newQty) } } - return m, nil + + // Pass messages to the detail viewport so pgup/pgdn scroll works + m.shop.detailViewport, cmd = m.shop.detailViewport.Update(msg) + cmds = append(cmds, cmd) + + if m.shop.viewportsReady { + m.shop.menuViewport.SetContent(m.getShopMenuContent()) + m.shop.detailViewport.SetContent(m.getShopDetailContent()) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) shopMoveSelected(previous bool) Model { + next := m.shop.selected + if previous { + next-- + } else { + next++ + } + if next < 0 { + next = 0 + } + if next >= len(m.Coffees) { + 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 } diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 8c9abe0..47b00c2 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -14,6 +14,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if tick, ok := msg.(resizeTickMsg); ok { if tick.seq == m.resizeSeq { m.updateLayout(m.pendingWidth, m.pendingHeight) + switch m.currentPage { + case shopPage: + m = m.updateShopViewports() + case cartPage: + m = m.updateCartViewport() + case shippingPage: + m = m.updateShippingViewport() + case paymentPage: + m = m.updatePaymentViewport() + case confirmPage: + m = m.updateConfirmViewport() + } if m.ShippingForm != nil { m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) } @@ -79,7 +91,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.OrdersLoaded = true m.splashDataReady = true if m.splashDelayDone { - return m.SwitchPage(shopPage), nil + m = m.SwitchPage(shopPage) + m = m.updateShopViewports() + return m, nil } return m, nil case CartSyncedMsg: @@ -120,7 +134,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case DelayCompleteMsg: m.splashDelayDone = true if m.currentPage == splashPage && m.splashDataReady { - return m.SwitchPage(shopPage), nil + m = m.SwitchPage(shopPage) + m = m.updateShopViewports() + return m, nil } return m, nil case splashCursorTickMsg: @@ -145,9 +161,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.menuLastPage = m.currentPage return m, nil case "s": - return m.SwitchPage(shopPage).resetPageState(), nil + m = m.SwitchPage(shopPage).resetPageState() + m = m.updateShopViewports() + return m, nil case "c": - return m.SwitchPage(cartPage).resetPageState(), nil + m = m.SwitchPage(cartPage).resetPageState() + m = m.updateCartViewport() + return m, nil case "a": m = m.SwitchPage(accountPage).resetPageState() if !m.OrdersLoaded { diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 4436f49..cf68d5c 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -116,33 +116,25 @@ func (m Model) View() string { } content = strings.Join(contentLines, "\n") - // Add vertical spacing around content with fixed height - contentWithPadding := lipgloss.NewStyle(). - MarginTop(marginTop). - MarginBottom(marginBottom). - Height(availableContentHeight). - Render(content) - - // Build footer footer := m.BuildFooter() - // Assemble all layers vertically within the container + isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage || + m.currentPage == shippingPage || m.currentPage == paymentPage || + m.currentPage == confirmPage + var child string - if breadcrumbs != "" { - child = lipgloss.JoinVertical( - lipgloss.Left, - header, - breadcrumbs, - contentWithPadding, - footer, - ) + if isViewportPage { + if breadcrumbs != "" { + child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, content, footer) + } else { + child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + } + } else if breadcrumbs != "" { + ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content) + child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, ccontentWithPadding, footer) } else { - child = lipgloss.JoinVertical( - lipgloss.Left, - header, - contentWithPadding, - footer, - ) + ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content) + child = lipgloss.JoinVertical(lipgloss.Left, header, ccontentWithPadding, footer) } // Constrain the assembled layout to the container dimensions @@ -166,23 +158,16 @@ func (m Model) buildPageContent(height int) string { case accountPage: return m.BuildAccountView(height) case shippingPage: - if m.ShippingView == 0 && m.ShippingForm == nil { - return m.RenderAddressList() - } - return m.RenderShippingForm(m.ShippingForm) + return m.ShippingPageView() case paymentPage: - if m.CheckingOut { - return " submitting order..." - } - if m.PaymentView == 0 && m.PaymentForm == nil { - return m.RenderCardList() - } - return m.RenderPaymentForm(m.PaymentForm) + return m.PaymentPageView() case confirmPage: - return m.RenderConfirmation() + return m.ConfirmView() case cartPage: - return m.BuildCartView() + return m.CartView() + case shopPage: + return m.ShopView() default: - return m.BuildShopView() + return m.ShopView() } }