Compare commits

..

5 Commits

Author SHA1 Message Date
7b08e794aa fix(tui): rework error and notice messages to be at the top of the tui, fixing the entire UI shifting when a message pops up 2026-06-19 18:17:14 +02:00
8288fa866c fix(tui): remove horrible code that was taking up space 2026-06-19 16:58:53 +02:00
9d5096b270 fix(privacy): forget saved card after privacy-mode checkout (#59) 2026-06-19 10:04:35 +02:00
85ed872c28 feat(privacy): add privacy mode (#58) 2026-06-19 09:31:44 +02:00
2c12ba586e feat(sca): charge checkout as customer-initiated transaction (#57)
Drop off_session from the initial PaymentIntent so the checkout charge is a Customer-Initiated Transaction (the customer is present in the TUI), not a Merchant-Initiated Transaction. MIT exemptions require a stored mandate we never capture; as a CIT, an SCA challenge surfaces as status=requires_action, the path the handler already had. The re-confirm in respondRequiresAction and the authentication_required error branch remain as defensive fallbacks. Source: stripe-samples accept-a-payment custom-payment-flow (no off_session on customer-present confirms).

Also clarify the MAX_ORDER_CENTS TRA-ceiling anchor comment as an approximate USD/EUR figure (config.go), and carry an unrelated 1-line spend-limit input width fix in pkg/tui/account.go.

Tests: add paymentIntentNewFn stub seam, TestConvertCartIsCustomerInitiated (asserts no off_session) and TestConvertCartRequiresAction (202 + redirect + audit row).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:48:00 +02:00
21 changed files with 990 additions and 74 deletions

View File

@@ -109,3 +109,53 @@ func (h *AccountHandler) SetSpendLimit(w http.ResponseWriter, r *http.Request) {
"self_limit_cents": limitCents,
})
}
// SetPrivacyMode sets the logged-in user's account-level privacy default
// (User.PrivacyMode). Body: {"privacy_mode": <bool>}.
//
// PrivacyMode is a SOFT default, not a server-enforced guarantee: it only
// pre-selects "don't keep" at checkout in the TUI. The checkout still sends an
// explicit per-order intent which the backend honors (cart.go:ConvertCart) —
// that is what lets a customer override the default with "save anyway". A
// stricter server-enforced "never save" would be a separate mode.
//
// Mirrors SetSpendLimit: JWT-authed, edits the caller's OWN row.
func (h *AccountHandler) SetPrivacyMode(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawMode, ok := raw["privacy_mode"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "privacy_mode is required", nil)
return
}
var enabled bool
if err := json.Unmarshal(rawMode, &enabled); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "privacy_mode must be a boolean", nil)
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// Map form so an explicit false persists (a struct Updates skips a zero bool)
if err := db.Model(&user).Updates(map[string]any{"privacy_mode": enabled}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update privacy mode", nil)
return
}
audit.UserPrivacyModeSet(user.ID, enabled)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"privacy_mode": enabled,
})
}

View File

@@ -0,0 +1,61 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// TestSetPrivacyMode covers the account privacy toggle: enable, disable (a real
// true->false transition, which the map-form Updates must persist), and the two
// 400 paths (missing field, wrong type).
func TestSetPrivacyMode(t *testing.T) {
cases := []struct {
name string
body string
wantStatus int
wantValue bool
}{
{"enable", `{"privacy_mode": true}`, http.StatusOK, true},
{"disable", `{"privacy_mode": false}`, http.StatusOK, false},
{"missing field", `{}`, http.StatusBadRequest, false},
{"wrong type", `{"privacy_mode": "yes"}`, http.StatusBadRequest, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
// Seed the opposite so an OK case proves a real transition (and the
// disable case actually exercises persisting an explicit false).
if tc.wantStatus == http.StatusOK {
if err := database.GetDB().Model(&user).Update("privacy_mode", !tc.wantValue).Error; err != nil {
t.Fatalf("seed privacy_mode: %v", err)
}
}
w := httptest.NewRecorder()
NewAccountHandler(0).SetPrivacyMode(
w, authRequest("PUT", "/api/v1/account/privacy-mode", []byte(tc.body), user.ID))
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String())
}
if tc.wantStatus != http.StatusOK {
return
}
var reloaded models.User
if err := database.GetDB().First(&reloaded, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if reloaded.PrivacyMode != tc.wantValue {
t.Errorf("privacy_mode: want %v, got %v", tc.wantValue, reloaded.PrivacyMode)
}
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
@@ -41,6 +42,11 @@ const maxAuthIssuances = 4
// paymentintent.New. Mirrors paymentIntentGetFn in reconcile.go.
var paymentIntentNewFn = paymentintent.New
var (
paymentMethodNewFn = paymentmethod.New
paymentMethodDetachFn = paymentmethod.Detach
)
// NewCartHandler creates a new cart handler. Stripe credentials are wired
// once at startup via stripe.Key in api/main.go.
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler {
@@ -269,6 +275,16 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
})
}
// ConvertCartRequest carries the optional privacy-checkout controls. The body
// is optional: a saved.card checkout sends nothing and both field stay zero.
// Ephemeral opts a single order into privacy mode (ont-time card, no Card row,
// PM detached after settle); CardToken supplies that one-time card. A user with
// PrivacyMode set is ephemeral regardless of the flag.
type ConvertCartRequest struct {
Ephemeral bool `json:"ephemeral"`
CardToken string `json:"card_token"`
}
func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
@@ -277,6 +293,12 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
defer span.End()
r = r.WithContext(ctx)
var req ConvertCartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
middleware.RecordCartConversion("cart_lookup_failed")
@@ -299,12 +321,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
if cart.CardID == nil {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
return
}
var address models.Address
if err := db.First(&address, *cart.AddressID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_address")
@@ -312,23 +328,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
var card models.Card
if err := db.First(&card, *cart.CardID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_card")
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
return
}
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
middleware.RecordCartConversion("user_not_found")
@@ -336,39 +335,90 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
// Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed")
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
// Per-order intent drives the charge SHAPE: req.Ephemeral picks the one-time
// token path (no saved card). Privacy mode adds a second guarantee on the
// saved-card path — the live QR add-card flow has no "save anyway" prompt, so
// a privacy user who selects a (QR-collected) card still must not have it
// retained. forgetCard charges that card normally, then detaches the
// PaymentMethod and hard-deletes the local Card row once the order settles.
// It never applies to the token path, which has no local row.
ephemeral := req.Ephemeral
forgetCard := user.PrivacyMode && !ephemeral
// 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)
var card models.Card
var paymentMethodID string
if ephemeral {
if req.CardToken == "" {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "card token is required for private checkout", nil)
return
}
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)
pm, pmerr := paymentMethodNewFn(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(req.CardToken),
},
})
if pmerr != nil {
middleware.RecordCartConversion("stripe_error")
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
} else {
if cart.CardID == nil {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
return
}
if err := db.First(&card, *cart.CardID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_card")
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
return
}
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed")
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
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
}
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
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
}
}
var subtotal int
@@ -442,6 +492,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
UserID: user.ID,
CardID: card.ID,
StripePaymentID: "",
Ephemeral: ephemeral || forgetCard,
Status: models.OrderStatusPending,
Subtotal: subtotal,
ShippingCost: cart.ShippingCost,
@@ -481,7 +532,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID),
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
@@ -504,6 +554,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
"order_id": fmt.Sprintf("%d", order.ID),
}
if !ephemeral {
piParams.Customer = stripe.String(user.StripeCustomerID)
}
stripeStart := time.Now()
pi, err := paymentIntentNewFn(piParams)
stripeDur := time.Since(stripeStart).Seconds()
@@ -575,6 +629,26 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
if ephemeral || forgetCard {
if _, derr := paymentMethodDetachFn(paymentMethodID, nil); derr != nil {
slog.Warn("failed to detach ephemeral payment method", "order_id", order.ID, "error", derr)
}
}
if forgetCard {
// Privacy mode: the card was persisted only long enough to select and
// charge it. Null the order's reference and hard-delete the local row so
// no card data (last4/brand/token) survives the checkout. Unscoped because
// Card uses gorm soft-delete; a plain delete would leave the data on disk.
// Mirrors upstream Card.remove (terminal-shop-source core/src/card/index.ts):
// detach the PM, drop the FK, delete the row.
if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
slog.Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
}
if err := db.Unscoped().Delete(&models.Card{}, card.ID).Error; err != nil {
slog.Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", card.ID, "error", err)
}
}
audit.OrderPaid(userID, order.ID, total, pi.ID)
middleware.RecordOrderCreated(string(order.Status))
middleware.RecordCartConversion("success")

View File

@@ -0,0 +1,421 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// seedEphemeralCart seeds an item + address on the user's cart but deliberately
// NO card — the ephemeral (privacy) path supplies a one-time token at checkout
// instead of selecting a saved card.
func seedEphemeralCart(t *testing.T, user models.User) {
t.Helper()
db := database.GetDB()
addr := models.Address{
UserID: user.ID, Name: "test", Street: "1 Main St",
City: "PDX", State: "OR", Zip: "97201", Country: "US",
}
if err := db.Create(&addr).Error; err != nil {
t.Fatalf("seed address: %v", err)
}
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", 500).Error; err != nil {
t.Fatalf("pin coffee price: %v", err)
}
h := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": 2})
w := httptest.NewRecorder()
h.SetItem(w, authRequest("PUT", "/api/v1/cart/item", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetItem: %d %s", w.Code, w.Body.String())
}
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
w = httptest.NewRecorder()
h.SetAddress(w, authRequest("PUT", "/api/v1/cart/address", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String())
}
}
// TestConvertCart_Ephemeral_InlineSuccess proves the privacy charge path: a
// one-time PaymentMethod is created from the token, the PaymentIntent attaches
// NO Customer, the PM is detached after settle, no Card row is written, and the
// order is paid + marked Ephemeral + keeps the PaymentIntent id (so refunds
// still work). The table covers both drivers: the per-request flag and the
// account-level PrivacyMode switch.
func TestConvertCart_Ephemeral_InlineSuccess(t *testing.T) {
cases := []struct {
name string
reqEphem bool // request "ephemeral"
}{
{"per-request flag", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
seedEphemeralCart(t, user)
var pmNewToken string
var piParams *stripe.PaymentIntentParams
var detached string
origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn
defer func() {
paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach
}()
paymentMethodNewFn = func(p *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
if p.Card != nil && p.Card.Token != nil {
pmNewToken = *p.Card.Token
}
return &stripe.PaymentMethod{ID: "pm_once"}, nil
}
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piParams = p
return &stripe.PaymentIntent{ID: "pi_eph", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": tc.reqEphem, "card_token": "tok_visa"})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
}
if pmNewToken != "tok_visa" {
t.Errorf("one-time PM token: want tok_visa, got %q", pmNewToken)
}
if piParams == nil {
t.Fatal("paymentIntentNewFn was never called")
}
if piParams.Customer != nil {
t.Errorf("ephemeral charge must NOT attach a Customer; got %q", *piParams.Customer)
}
if piParams.PaymentMethod == nil || *piParams.PaymentMethod != "pm_once" {
t.Error("PI must charge the one-time PM pm_once")
}
if detached != "pm_once" {
t.Errorf("PM must be detached after settle; detached=%q", detached)
}
var cardCount int64
db.Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("ephemeral checkout must not save a Card row; found %d", cardCount)
}
var order models.Order
if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("order.Ephemeral must be true")
}
if order.CardID != 0 {
t.Errorf("ephemeral order must have CardID 0, got %d", order.CardID)
}
if order.Status != models.OrderStatusPaid {
t.Errorf("status: want paid, got %q", order.Status)
}
if order.StripePaymentID != "pi_eph" {
t.Errorf("order must keep the PaymentIntent id for refunds, got %q", order.StripePaymentID)
}
})
}
}
// TestConvertCart_Ephemeral_MissingTokenRejected: a privacy checkout with no
// card token is a 400, and we never reach Stripe.
func TestConvertCart_Ephemeral_MissingTokenRejected(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
seedEphemeralCart(t, user)
piCalled := false
origPI := paymentIntentNewFn
defer func() { paymentIntentNewFn = origPI }()
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piCalled = true
return &stripe.PaymentIntent{ID: "pi_x", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": true}) // no card_token
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d body=%s", w.Code, w.Body.String())
}
if piCalled {
t.Error("must not create a PaymentIntent when the card token is missing")
}
}
// TestConvertCart_Ephemeral_RequiresActionDefersDetach: on a 3DS challenge the
// charge has not settled, so the PM must stay attached (the webhook detaches it
// later). The order persists Ephemeral so the webhook knows to detach.
func TestConvertCart_Ephemeral_RequiresActionDefersDetach(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
seedEphemeralCart(t, user)
detachCalled := false
origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn
defer func() {
paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach
}()
paymentMethodNewFn = func(_ *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
return &stripe.PaymentMethod{ID: "pm_once_3ds"}, nil
}
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: "pi_eph_3ds",
Status: stripe.PaymentIntentStatusRequiresAction,
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{URL: "https://hooks.stripe.com/3ds/test"},
},
}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detachCalled = true
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": true, "card_token": "tok_3ds"})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
if detachCalled {
t.Error("must NOT detach the PM while 3DS is pending; the webhook owns the deferred detach")
}
var order models.Order
if err := database.GetDB().Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("order.Ephemeral must persist so the webhook can detach later")
}
if order.Status != models.OrderStatusRequiresAction {
t.Errorf("status: want requires_action, got %q", order.Status)
}
}
// TestHandlePaymentIntentSucceeded_EphemeralDetach exercises the deferred (3DS)
// detach: an Ephemeral order finishing via webhook detaches its PM; a
// non-ephemeral one does not.
func TestHandlePaymentIntentSucceeded_EphemeralDetach(t *testing.T) {
cases := []struct {
name string
ephemeral bool
wantDetach string // expected detached PM id, "" if none
}{
{"ephemeral order detaches PM", true, "pm_eph_once"},
{"non-ephemeral order keeps PM", false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
order := models.Order{
UserID: user.ID, CardID: 0, Ephemeral: tc.ephemeral,
StripePaymentID: "pi_eph_webhook", Status: models.OrderStatusRequiresAction,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR", ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
detached := ""
origDetach := paymentMethodDetachFn
defer func() { paymentMethodDetachFn = origDetach }()
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"currency": "usd",
"payment_method": "pm_eph_once",
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, _ := json.Marshal(pi)
evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}}
NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("status: want paid, got %q", reloaded.Status)
}
if detached != tc.wantDetach {
t.Errorf("detach: want %q, got %q", tc.wantDetach, detached)
}
})
}
}
// TestConvertCart_PrivacyModeForgetsCard proves the privacy guarantee on the
// saved-card path: a privacy_mode user who checks out a saved (QR-collected) card
// is charged normally (Customer attached — the PM is customer-attached, so the
// charge requires it), but once the charge settles the PaymentMethod is detached
// and the local Card row is hard-deleted, and the order is marked Ephemeral. The
// live QR add-card flow has no "save anyway" prompt, so privacy mode is the
// authoritative signal here.
func TestConvertCart_PrivacyModeForgetsCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
if err := db.Model(&user).Update("privacy_mode", true).Error; err != nil {
t.Fatalf("set privacy_mode: %v", err)
}
seedConvertReadyCart(t, user) // seeds a SAVED card + address + item
var piParams *stripe.PaymentIntentParams
detached := ""
origPI, origDetach := paymentIntentNewFn, paymentMethodDetachFn
defer func() { paymentIntentNewFn, paymentMethodDetachFn = origPI, origDetach }()
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piParams = p
return &stripe.PaymentIntent{ID: "pi_forget", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": false})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
}
// The PM is attached to the Stripe customer, so the charge must still set
// Customer — you cannot charge an attached PM without it.
if piParams == nil || piParams.Customer == nil {
t.Error("saved-card charge must attach a Customer")
}
// ...but privacy mode forgets it afterward: detach + hard-delete.
if detached != "pm_test_cit" {
t.Errorf("forgotten card PM must be detached after settle; detached=%q", detached)
}
var cardCount int64
db.Unscoped().Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("privacy checkout must HARD-delete the local card row; found %d", cardCount)
}
var order models.Order
if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("forgotten-card order must be marked Ephemeral")
}
if order.CardID != 0 {
t.Errorf("forgotten-card order must null its CardID, got %d", order.CardID)
}
if order.Status != models.OrderStatusPaid {
t.Errorf("status: want paid, got %q", order.Status)
}
}
// TestHandlePaymentIntentSucceeded_ForgetsCard covers the deferred (3DS) twin of
// the inline forget-card cleanup: an Ephemeral order that still carries a local
// Card row (CardID != 0, the privacy + saved/QR-card case) has that row
// hard-deleted and its CardID nulled when the webhook marks it paid.
func TestHandlePaymentIntentSucceeded_ForgetsCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_forget_webhook",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID, Ephemeral: true,
StripePaymentID: "pi_forget_webhook", Status: models.OrderStatusRequiresAction,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR", ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
origDetach := paymentMethodDetachFn
defer func() { paymentMethodDetachFn = origDetach }()
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
return &stripe.PaymentMethod{ID: id}, nil
}
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"currency": "usd",
"payment_method": "pm_forget_webhook",
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, _ := json.Marshal(pi)
evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}}
NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt)
var cardCount int64
db.Unscoped().Model(&models.Card{}).Where("id = ?", card.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("webhook must hard-delete the forgotten card; found %d", cardCount)
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Errorf("status: want paid, got %q", reloaded.Status)
}
if reloaded.CardID != 0 {
t.Errorf("order CardID must be nulled, got %d", reloaded.CardID)
}
}

View File

@@ -174,12 +174,33 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return
}
if order.CardID != 0 {
// A retained (non-ephemeral) card gets its storage TTL refreshed on use.
// Ephemeral orders are about to lose their card entirely, so skip the refresh.
if order.CardID != 0 && !order.Ephemeral {
if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil {
webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err)
}
}
if order.Ephemeral && pi.PaymentMethod != nil && pi.PaymentMethod.ID != "" {
if _, derr := paymentMethodDetachFn(pi.PaymentMethod.ID, nil); derr != nil {
webhookLog().Warn("failed to detach ephemeral payment method",
"order_id", order.ID, "pi", pi.ID, "error", derr)
}
}
// Deferred (3DS) twin of the inline forget-card cleanup in ConvertCart: a
// privacy-mode order that selected a saved/QR card carries CardID != 0. Now
// that it is paid, hard-delete the local row (Unscoped: Card soft-deletes by
// default). Token-ephemeral orders have CardID == 0 and skip this.
if order.Ephemeral && order.CardID != 0 {
if err := db.Unscoped().Delete(&models.Card{}, order.CardID).Error; err != nil {
webhookLog().Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", order.CardID, "error", err)
} else if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
webhookLog().Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
}
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
go notify.SlackOrderPaid(&order)
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)

View File

@@ -110,6 +110,7 @@ func SetupRoutes(
// own row (user id from context, never a URL param). Distinct from
// the admin /users/{id}/order-cap setter in the RequireAdmin group.
r.Put("/account/spend-limit", accountHandler.SetSpendLimit)
r.Put("/account/privacy-mode", accountHandler.SetPrivacyMode)
// Cart
r.Route("/cart", func(r chi.Router) {

View File

@@ -491,6 +491,39 @@ func (c *Client) SetSpendLimit(cents *int) error {
return nil
}
// SetPrivacyMode sets the users account-level privacy default ("keep as little
// as possible"). A soft default the checkout can override per order.
func (c *Client) SetPrivacyMode(on bool) error {
url := fmt.Sprintf("%s/api/v1/account/privacy-mode", c.BaseURL)
jsonData, err := json.Marshal(map[string]any{"privacy_mode": on})
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("PUT", url, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to set privacy mode: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if !result.Success {
if result.Error != nil {
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
}
}
return nil
}
// ClearCart removes all items from the user's cart.
func (c *Client) ClearCart() error {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
@@ -525,9 +558,38 @@ func (c *Client) ClearCart() error {
// Returns a CheckoutOutcome that either carries the completed Order or
// indicates the customer must complete 3DS authentication via RedirectURL.
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
return c.convertCart(nil)
}
// ConvertCartEphemeral converts the cart in privacy mode: it charges a one-time
// card (cardToken) that is never saved and is detached after the charge settles.
// Used when the customer leaves "save this card" unchecked at checkout.
func (c *Client) ConvertCartEphemeral(cardToken string) (*CheckoutOutcome, error) {
jsonData, err := json.Marshal(map[string]any{
"ephemeral": true,
"card_token": cardToken,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
return c.convertCart(jsonData)
}
// convertCart POSTs to /cart/convert with an optional JSON body (nil = saved-card
// flow) parses the shared response into a CheckoutOutcome.
func (c *Client) convertCart(jsonData []byte) (*CheckoutOutcome, error) {
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
req, err := http.NewRequest("POST", url, nil)
var req *http.Request
var err error
if jsonData != nil {
req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
} else {
req, err = http.NewRequest("POST", url, nil)
}
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

View File

@@ -52,6 +52,7 @@ const (
eventTrackingMarkedManually eventType = "tracking_marked_manually"
eventUserOrderCapSet eventType = "user_order_cap_set"
eventUserSelfLimitSet eventType = "user_self_limit_set"
eventUserPrivacyModeSet eventType = "user_privacy_mode_set"
)
type event struct {
@@ -425,3 +426,21 @@ func UserSelfLimitSet(userID uint, capCents *int) {
}
slog.Info("audit", e.attrs()...)
}
// UserPrivacyModeSet records a customer toggling their account-level privacy
// ("keep as little as possible") default via the JWT-authed account endpoint.
// Actor "self" mirrors UserSelfLimitSet; Status carries the new state so the
// trail reads cleanly without a dedicated column.
func UserPrivacyModeSet(userID uint, enabled bool) {
e := event{
Event: eventUserPrivacyModeSet,
Actor: "self",
UserID: userID,
}
if enabled {
e.Status = "enabled"
} else {
e.Status = "disabled"
}
slog.Info("audit", e.attrs()...)
}

View File

@@ -34,6 +34,7 @@ var AccountMenuItems = []string{
"addresses",
"cards",
"spend limit",
"privacy",
"faq",
"about",
}

View File

@@ -37,6 +37,7 @@ type Order struct {
UserID uint `gorm:"not null;index" json:"user_id"`
CardID uint `gorm:"not null" json:"card_id"`
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
Ephemeral bool `gorm:"default:false" json:"ephemeral"`
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents

View File

@@ -16,6 +16,7 @@ type User struct {
Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts)
Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key
StripeCustomerID string `gorm:"size:255" json:"-"`
PrivacyMode bool `gorm:"default:false" json:"privacy_mode"`
// MaxOrderCents is an optional per-user override of the global MAX_ORDER_CENTS
// spend cap. It may raise OR lower the global cap — both directions are
// intentional (e.g. a vetted corporate buyer gets a higher ceiling; a flagged
@@ -42,6 +43,7 @@ type PublicUser struct {
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
PrivacyMode bool `json:"privacy_mode"`
CreatedAt time.Time `json:"created_at"`
}
@@ -53,6 +55,7 @@ func (u *User) ToPublic() PublicUser {
Name: u.Name,
Email: u.Email,
SelfLimitCents: u.SelfLimitCents,
PrivacyMode: u.PrivacyMode,
CreatedAt: u.CreatedAt,
}
}

15
pkg/models/user_test.go Normal file
View File

@@ -0,0 +1,15 @@
package models
import "testing"
// TestToPublic_IncludesPrivacyMode guards the converter: PublicUser must carry
// the user's PrivacyMode so the TUI can reflect the account toggle's real state.
// (Regression: ToPublic originally omitted this field and always reported false.)
func TestToPublic_IncludesPrivacyMode(t *testing.T) {
for _, want := range []bool{true, false} {
u := User{ID: 1, PrivacyMode: want}
if got := u.ToPublic().PrivacyMode; got != want {
t.Errorf("ToPublic().PrivacyMode = %v, want %v", got, want)
}
}
}

View File

@@ -20,7 +20,7 @@ import (
func (m Model) accountBodyHeight() int {
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
budget := m.heightContainer - headerHeight - footerHeight - 2
budget := m.heightContainer - headerHeight - footerHeight - 2 - 1
cards := budget / orderCardHeight
cards = max(cards, 1)
return cards*orderCardHeight - 2
@@ -85,6 +85,8 @@ func (m Model) getAccountDetailContent() string {
return m.CardsView(detailContentWidth)
case "spend limit":
return m.SpendLimitView(detailContentWidth)
case "privacy":
return m.PrivacyView(detailContentWidth)
case "faq":
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
@@ -279,6 +281,24 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}
if saved, ok := msg.(privacyModeSavedMsg); ok {
m.account.privacyModeSaving = false
if saved.Err != nil {
m.account.privacyModeErr = saved.Err.Error()
return m, nil
}
if m.User != nil {
m.User.PrivacyMode = saved.Enabled
}
m.account.privacyModeErr = ""
state := "off"
if saved.Enabled {
state = "on"
}
m.notice = &VisibleNotice{message: "Privacy mode " + state}
return m, nil
}
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
// Pass non-key messages to viewport (e.g. window resize)
@@ -483,6 +503,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
return m, cmd
}
case "privacy":
return m.togglePrivacyMode()
case "faq":
if !m.account.faqFocused {
m.account.faqFocused = true
@@ -614,6 +636,88 @@ func (m Model) setSpendLimitCmd(cents *int) tea.Cmd {
}
}
// privacyModeSavedMsg carries the result of an async SetPrivacyMode call back to
// AccountUpdate. Enabled echoes the value sent so the view updates without a
// refetch; Err is non-nil when the save failed.
type privacyModeSavedMsg struct {
Enabled bool
Err error
}
// PrivacyView renders the account-level privacy toggle ("keep as little as
// possible"). A soft default: the checkout can still override it per order.
func (m Model) PrivacyView(width int) string {
labelStyle := m.theme.TextLabel()
bodyStyle := m.theme.TextBody().Width(width)
hintStyle := m.theme.TextDim()
state := "off"
if m.User != nil && m.User.PrivacyMode {
state = "on"
}
// Privacy copy is split into short labelled sections instead of one wall of
// text, reusing the same labelStyle/bodyStyle pairing as the rest of the
// account view so it reads as scannable blocks.
sections := []struct{ title, body string }{
{
"Philosophy",
"Regardless of privacy mode, I never keep your full card number. Full stop.",
},
{
"Privacy disabled",
"Only the last 4 digits, card brand and a payment token is stored",
},
{
"Privacy enabled",
"With privacy mode ENABLED checkout uses a one-time card. All of the data is wiped the millisecond you pay",
},
{
"Your address",
"This one I have to keep. The law makes me, sometimes for years. So I store it offline, on my own network, where only I can reach it. Sorry about that.",
},
}
out := ""
for _, s := range sections {
out += labelStyle.Render(s.title) + "\n"
out += bodyStyle.Render(wordWrap(s.body, width)) + "\n\n"
}
out += labelStyle.Render("Privacy mode") + "\n"
out += m.theme.TextAccent().Render(state) + "\n\n"
switch {
case m.account.privacyModeSaving:
out += m.theme.TextAccent().Render("saving...") + "\n"
case m.account.privacyModeErr != "":
out += m.theme.TextError().Render(m.account.privacyModeErr) + "\n"
}
return out + hintStyle.Render("enter: toggle")
}
// togglePrivacyMode flips the account privacy default and fires the save command.
func (m Model) togglePrivacyMode() (Model, tea.Cmd) {
if m.account.privacyModeSaving {
return m, nil
}
current := m.User != nil && m.User.PrivacyMode
m.account.privacyModeErr = ""
m.account.privacyModeSaving = true
return m, m.setPrivacyModeCmd(!current)
}
// setPrivacyModeCmd calls the API off the UI thread and reports back via
// privacyModeSavedMsg. Mirrors setSpendLimitCmd.
func (m Model) setPrivacyModeCmd(on bool) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return privacyModeSavedMsg{Err: fmt.Errorf("not authenticated")}
}
err := m.APIClient.SetPrivacyMode(on)
return privacyModeSavedMsg{Enabled: on, Err: err}
}
}
// parseSpendLimitInput maps the raw dollar input to a *int of cents. Blank
// clears the limit (nil -> revert to admin/global). "0" is a real "block
// everything" ceiling, NOT a clear. Negative, non-numeric, or more-than-2-

View File

@@ -14,7 +14,7 @@ func (m Model) updateCartViewport() Model {
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
// 2 = blank gap rows around the cart list
availH := m.heightContainer - headerH - footerH - breadH - 2
availH := m.heightContainer - headerH - footerH - breadH - 2 - 1
if availH < 1 {
availH = 1
}

View File

@@ -70,7 +70,7 @@ 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
availH := m.heightContainer - headerH - footerH - breadH - 1
if availH < 1 {
availH = 1
}

View File

@@ -196,6 +196,8 @@ type accountState struct {
spendLimitInput textinput.Model
spendLimitSaving bool
spendLimitErr string
privacyModeSaving bool
privacyModeErr string
detailViewport viewport.Model
viewportReady bool
}
@@ -681,6 +683,51 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
}
}
// checkoutEphemeralCmd tokenizes the entered card and charges it as a one-time,
// never-saved card (privacy checkout). Mirrors checkoutWithSavedCard but routes
// to ConvertCartEphemeral instead of saving the card + SetCartCard. The token is
// produced the same way as saveCardOnlyCmd (stripetoken.New) and handed straight
// to the charge — no Card row, no SelectedCard, and the backend detaches the
// PaymentMethod once the charge settles.
func (m Model) checkoutEphemeralCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CheckoutResultMsg{Err: fmt.Errorf("API client not available")}
}
if err := m.resolveCartAddress(); err != nil {
return CheckoutResultMsg{Err: err}
}
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 {
if stripeErr, ok := err.(*stripe.Error); ok {
return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
}
return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
}
outcome, err := m.APIClient.ConvertCartEphemeral(tok.ID)
if err != nil {
return CheckoutResultMsg{Err: err}
}
if outcome.RequiresAction {
return CheckoutResultMsg{
OrderID: outcome.OrderID,
RequiresAction: true,
RedirectURL: outcome.RedirectURL,
}
}
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
}
}
// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while
// waiting for the customer to complete 3DS authentication.
func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {

View File

@@ -26,6 +26,7 @@ type PaymentFormState struct {
ExpiryYear string
CVC string
BillingZip string
SaveCard bool
submitting bool
}
@@ -38,6 +39,7 @@ type PaymentFormCompleteMsg struct {
ExpiryYear string
CVC string
BillingZip string
SaveCard bool
}
// CardSavedForReviewMsg is sent after a new card is tokenized and saved
@@ -77,6 +79,13 @@ type PollPaymentTimeoutMsg struct{}
// 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 {
// The save prompt doubles as the privacy "save anyway?" warning: when the
// account privacy default is on, the affirmative choice opposes it, so the
// title says so. The user can still pick Save (the per-order override).
savePrompt := "Save this card for next time?"
if m.User != nil && m.User.PrivacyMode {
savePrompt = "Privacy mode is on — saving keeps this card. Save anyway?"
}
f := huh.NewForm(
huh.NewGroup(
huh.NewInput().
@@ -109,6 +118,12 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
Title("Billing ZIP").
Key("billing_zip").
Value(&state.BillingZip),
huh.NewConfirm().
Key("save_card").
Title(savePrompt).
Affirmative("Save").
Negative("Don't save").
Value(&state.SaveCard),
),
).
WithShowErrors(false).
@@ -128,6 +143,11 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
func (m Model) InitPaymentForm() *PaymentFormState {
state := &PaymentFormState{}
// Default the save choice from the account privacy setting: privacy users
// default to NOT saving (a one-time card); everyone else keeps today's
// behaviour of saving the entered card.
state.SaveCard = m.User == nil || !m.User.PrivacyMode
// Pre-fill name and email from user if available
if m.User != nil {
if m.User.Name != "" {
@@ -241,6 +261,7 @@ func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
ExpiryYear: state.ExpiryYear,
CVC: state.CVC,
BillingZip: state.BillingZip,
SaveCard: state.SaveCard,
}
}
}
@@ -275,7 +296,7 @@ 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
availH := m.heightContainer - headerH - footerH - breadH - 1
availH = max(availH, 1)
if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
@@ -419,7 +440,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
case PaymentFormCompleteMsg:
if m.payment.form != nil && !m.payment.form.submitting {
m.payment.form.submitting = true
return m, m.saveCardOnlyCmd(msg)
if msg.SaveCard {
return m, m.saveCardOnlyCmd(msg)
}
// Privacy checkout: charge a one-time card without saving it. Jump to
// the review page in checking-out state so ReviewUpdate handles the
// resulting CheckoutResultMsg (same terminal handling as the saved-card
// path), then forget the card.
m, _ = m.ReviewSwitch()
m.CheckingOut = true
return m, m.checkoutEphemeralCmd(msg)
}
return m, nil

View File

@@ -257,7 +257,7 @@ 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
availH := m.heightContainer - headerH - footerH - breadH - 1
if availH < 1 {
availH = 1
}

View File

@@ -93,4 +93,8 @@ func (t Theme) PanelError() lipgloss.Style {
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
}
func (t Theme) PanelNotice() lipgloss.Style {
return t.Base().Background(lipgloss.Color("#009000")).Foreground(lipgloss.Color("#00FF00"))
}
func (t Theme) Form() huh.Theme { return t.form }

View File

@@ -102,6 +102,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Name: msg.Data.User.Email,
Email: msg.Data.User.Email,
SelfLimitCents: msg.Data.User.SelfLimitCents,
PrivacyMode: msg.Data.User.PrivacyMode,
}
m.User = &u
m.Username = u.Name

View File

@@ -67,7 +67,8 @@ func (m Model) renderString() string {
marginTop := 1
marginBottom := 1
bufferSpace := 1
reservedHeight := headerHeight + breadcrumbsHeight + footerHeight + marginTop + marginBottom + bufferSpace
bannerHeight := 1
reservedHeight := bannerHeight + headerHeight + breadcrumbsHeight + footerHeight + marginBottom + marginTop + bufferSpace
availableContentHeight := m.heightContainer - reservedHeight
// Ensure minimum content height
@@ -81,22 +82,21 @@ func (m Model) renderString() string {
if m.Loading {
loadingStyle := m.theme.TextLoading().Padding(2, 4)
content = loadingStyle.Render("Loading products from API...")
} else if m.error != nil {
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
// soft-wrap long messages instead of clipping.
if m.widthContent > 4 {
errorStyle = errorStyle.Width(m.widthContent - 4)
}
errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent()
} else if m.notice != nil {
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
noticeBanner := noticeStyle.Render(m.notice.message)
content = noticeBanner + "\n" + m.buildPageContent()
} else {
content = m.buildPageContent()
}
bannerW := lipgloss.Width(header) - 2
var topBanner string
switch {
case m.error != nil:
topBanner = m.theme.PanelError().Width(bannerW).MaxWidth(bannerW).Render(m.error.message)
case m.notice != nil:
topBanner = m.theme.PanelNotice().Width(bannerW).MaxWidth(bannerW).Render(m.notice.message)
default:
topBanner = lipgloss.NewStyle().Width(m.widthContent).Render("")
}
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage || m.currentPage == accountPage
@@ -104,20 +104,21 @@ func (m Model) renderString() string {
var child string
if isViewportPage {
if breadcrumbs != "" {
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, content, footer)
child = lipgloss.JoinVertical(lipgloss.Left, topBanner, header, breadcrumbs, content, footer)
} else {
child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
child = lipgloss.JoinVertical(lipgloss.Left, topBanner, header, content, footer)
}
} else if breadcrumbs != "" {
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, contentWithPadding, footer)
child = lipgloss.JoinVertical(lipgloss.Left, topBanner, header, breadcrumbs, contentWithPadding, footer)
} else {
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, contentWithPadding, footer)
child = lipgloss.JoinVertical(lipgloss.Left, topBanner, header, contentWithPadding, footer)
}
// Constrain the assembled layout to the container dimensions
constrained := lipgloss.NewStyle().
Width(m.widthContainer).
MaxWidth(m.widthContainer).
MaxHeight(m.heightContainer).
Render(child)