Compare commits

..

33 Commits

Author SHA1 Message Date
b14f21bc06 feat(sca): wire retry-auth keybind on authfailed review screen 2026-06-05 22:46:09 +02:00
732353d9a4 fix(tui): fix the menu always selecting the first entry in the refund page (#32) 2026-06-04 00:09:07 +02:00
3420c8dce0 feat(sca): add retry-auth endpoint for failed 3ds challenges (#31)
* feat(sca): add retry-auth endpoint for failed 3ds challenges

* fix(sca): retry-auth unexpected-state branch out of card-decline

just fixes up in some of the things the review of the last commit
noticed
2026-06-03 23:42:27 +02:00
f3b94c3271 chore(test): add gotestsum + tparse runner with ci wiring (#30)
* chore(test): add gotestsum + tparse runner with ci wiring

* chore(tests): change the tests around a little
2026-06-02 23:17:03 +02:00
67e19eca98 feat(sca): expire inactive saved cards (#29)
* feat(sca): expire inactive saved cards

* fix(sca): address card ttl review feedback

- Correct sql typos breaking GET /cards and /view/init (used_id, id_default)
- Stop swallowing backfillCardStorageExpiration error in Migrate
- Wrap expireStoredCard local writes in a transaction and run stripe
  detach + audit only after commit, preventing double-fire on retry
- Drop redundant ttl refresh in ConvertCart; webhook is authoritative
- Rename validation_expired_at metric label to validation_card_expired
- Fix CardStorageExpiresAt doc comment and test failure message typos
- Add handler-level tests for expireStoredCards (user scoping + cart deref)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(sca): move card expiration to periodic reconciler

Read paths (GetCards, GetViewInit) now filter expired cards from
query results instead of detaching them inline, so request handlers
no longer block on Stripe API calls. Physical deletion runs in a
new ReconcileExpiredCards goroutine on an hourly ticker, mirroring
the existing ReconcileOrders / ReconcileUnshipped jobs. Per-card
action sites (SetCard, SetDefaultCard, ConvertCart) still expire
synchronously since they operate on one row the user is acting on.

ViewHandler's stripeKey dependency is dropped now that GetViewInit
no longer touches Stripe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sca): address card ttl review feedback

- guard zero order.CardID before refreshing card storage ttl in
  payment_intent.succeeded webhook to avoid noisy warn logs on payment
  paths that did not use a saved card
- delete unused Card.RefreshStorageTTL model method; the SQL-only
  refreshCardStorageTTL helper is the single canonical path
- clarify duplicate-card webhook path: storage_expires_at reset is a
  card-add refresh, not a payment-success refresh, so last_used_at
  intentionally stays untouched

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(sca): cover card ttl read paths and expired-card sync sites

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:49:48 +02:00
728ac24c96 fix(sca): return over-limit checkout errors to cart (#28) 2026-06-02 10:28:56 +02:00
1096c85b10 chore(ops): add nightly backup verification scripts (#27) 2026-06-02 00:02:27 +02:00
2f65cf0d90 feat(sca): cap per-order total ro prevent oversize charges (#26) 2026-06-01 22:40:20 +02:00
c3e6eb2b84 fix(ci): pin deploy go-version to go.mod to match ci.yml (#25) 2026-05-30 23:29:19 +02:00
03009ea6df chore(ci): bump checkout and setup-go to node24-ready majors (#24) 2026-05-30 23:19:29 +02:00
c8b7545ca6 chore(ci): skip golangci-lint under act to avoid node path issue (#23) 2026-05-30 19:48:20 +02:00
3a3c7ee80b feat(sca): persist redirect tokens to sqlite with 10min ttl (#22) 2026-05-30 14:12:57 +02:00
7c8a2b562f feat(sca): handle 3ds checkout end to end with qr poll and race fix (#21)
* feat(sca): handle 3ds checkout end to end with qr poll and race fix

* fix(sca): drop unused pollOrderStatusCmd helper
2026-05-28 12:41:36 +02:00
1c5765d25b fix(sca): pass payment method on reconfirm so 3ds next_action surfaces (#20) 2026-05-28 12:20:35 +02:00
e6301e662f fix(sca): re-confirm payment intent on-session to surface 3ds next_action (#19) 2026-05-28 12:05:41 +02:00
8b2889a524 fix(sca): re-fetch payment intent when error response omits next_action (#18) 2026-05-28 11:53:54 +02:00
c2b2b6e834 fix: allow redirect next_action so 3ds challenge url surfaces (#17) 2026-05-28 11:37:55 +02:00
bba5a0dfa0 feat(sca): return 202 + 3ds redirect token when checkout requires auth (#16) 2026-05-28 11:20:16 +02:00
a8e45bc82b feat(sca): map stripe error codes to user-facing copy in tui (#15) 2026-05-28 10:28:47 +02:00
95b9fa91b1 feat(sca): add order status endpoint adn SCA error code mapping (#14)
* feat(sca): add order status endpoint adn SCA error code mapping

adds server-side infra for the upcoming SCA / 3DS challenge flow im
working on. No behavior change to existing checkout paths.

* fix(sca): suppress unused lint on SCA scaffolding helpers
2026-05-27 19:23:29 +02:00
0fe013c399 feat(cart): pass off_session=true on PaymentIntent for SCA exemption eligibility (#13) 2026-05-27 18:09:50 +02:00
d9404ab31e style(tui): update refund page ui (#12) 2026-05-25 23:18:42 +02:00
a21485f91d fix(tui): way to much to write (#11) 2026-05-25 22:34:25 +02:00
bce94b6d10 feat(refund): add refund request flow (#10)
* feat(refund): add refund request flow

- tui modal composer with reason select + free-form message
  (pkg/tui/refund.go)
- POST /api/v1/orders/{id}/refund-request posts to orders slack thread
- 15 min server side cooldown via Order.LastRefundRequestAt (429 +
  retry-after)
- shared refundrequestreasons + can requestrefund hoisted to pkg/models
- slack message includes customer name, email, order total, quoted body
- tests over invalid-state, missing-message, and cooldown paths

* style(tui): run gofmt to fix formatting
2026-05-25 22:04:03 +02:00
dee6172875 refactor(tui): port to charm v2 (#9)
* refactor(tui): port to charm v2

bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly
mechanical chur, new import paths, vewport getter/setter api, view()
returns tea.view now, huh.theme became an interface so the theme package
needed a small reshape. Render plumbing is gone (no more lipgloss.Render
field on the Model or MakeRenderer in main.go). All pages still work
over ssh. setting up for the refund composer next

* style(tui): gofmt main.go and menu.go
2026-05-25 19:09:16 +02:00
b404303f84 feat(notify): daily slack reminder for paid orders awaiting labels (#8)
so the ain was that older paid orders were getting buried in the #orders
channel in slack and i would just forget about them. Pin/unpin from
earlier helped, but if i missed pinning one or the channel got noisy, it
was still easy to lose track.

this adds a 24h cron that queries any paid order older than 24h with no
label yet and dumps them all into one #orders message. Each line shows
the order id, how old it is, total, where it is going, and a permalink
back to the orders slack thread so i can hop in and hit Buy Label

Ports the idea from terminal.shops cron/unshipped.ts but out stuch state
is different( we auto-promote paid->shipped on label-buy so
PRE_TRANSIT-stuck can not happen), so the query is "paid + no tracking
number + >24h" instead.

NO orders get a "Bring blocked" tag since i dont have the norwegian org
set up yet and can not actually ship those today. Also fires once 40s
after startup so a deploy does not black out the reminder for a full
day.
2026-05-25 16:45:15 +02:00
48d65c48fa feat(tui): render customer-facing event timeline in order detail veiew (#7)
Pulls events off the new order_events table and shows them as a timeline
  block in the order detail view: Paid, Label created, In transit,
  Delivered (etc) with timestamps. Filters out ops-only events like
  payment_critical, label_orphaned, and tracking_marked_manually so the
  customer never sees internal plumbing.

  DisplayLabel on OrderEvent decides what's customer-visible and what's
  redacted, so the same order_events rows can later power an admin view
  with no filter.
2026-05-24 20:14:14 +02:00
c78021f520 docs(comments): add some comments (#6) 2026-05-24 18:57:58 +02:00
c429c1966e docs(comments): add some comments (#5)
this is the subject of the commit
2026-05-24 18:44:25 +02:00
50e1ba32f3 docs(comments): add some comments (#4) 2026-05-24 12:26:34 +02:00
85b0c53c0a docs(comments): cleanup comments (#3) 2026-05-24 12:02:50 +02:00
366d80a9ee test(dillydallying): testing the ci workflow (#2) 2026-05-23 22:12:46 +02:00
60f52f9851 add ci (#1)
* ci(checks): add pull request workflow and golangci-lint config

* refactor(lint): resolve golangci-lint findings

* style(tui): drop traling blank line for gofmt

* fix(cards): handle crypto/rand.Read error in storeRedirect
2026-05-23 21:43:30 +02:00
15 changed files with 96 additions and 105 deletions

View File

@@ -42,7 +42,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
var addresses []models.Address
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"addresses": addresses,
})
}
@@ -154,7 +154,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
"address": address,
})
}
@@ -183,7 +183,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address successfully deleted",
})
}
@@ -218,7 +218,7 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"address": address,
})
}

View File

@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
var resp struct {
Success bool `json:"success"`
Data struct {
Addresses []any `json:"addresses"`
Addresses []interface{} `json:"addresses"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {

View File

@@ -108,7 +108,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
}
middleware.RecordAuthAttempt("success")
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"access_token": token,
"token_type": "Bearer",
"expires_in": 1800, // 30 minutes in seconds
@@ -146,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
}
if len(errors) > 0 {
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
"errors": errors,
})
return
@@ -173,7 +173,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
return
}
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
"user": user.ToPublic(),
})
}
@@ -195,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"user": user.ToPublic(),
})
}

View File

@@ -89,12 +89,12 @@ func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
userID, time.Now(),
).Order("is_default DESC, created_at DESC").Find(&cards)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cards": cards,
})
}
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any)
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
// GetCard returns a single saved card by ID.
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
@@ -114,7 +114,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"card": card,
})
}
@@ -156,7 +156,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"card": card,
})
}
@@ -197,7 +197,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})
}
@@ -302,7 +302,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
"card": card,
})
}
@@ -347,7 +347,7 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
audit.CardDeleted(userID, card.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card deleted",
})
}

View File

@@ -55,18 +55,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
return &cart, nil
}
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
db := database.GetDB().WithContext(ctx)
var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
subtotal := 0
itemsData := make([]map[string]any, 0, len(items))
itemsData := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal
itemsData = append(itemsData, map[string]any{
itemsData = append(itemsData, map[string]interface{}{
"id": item.ID,
"coffee_id": item.CoffeeID,
"quantity": item.Quantity,
@@ -75,7 +75,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
})
}
return map[string]any{
return map[string]interface{}{
"items": itemsData,
"subtotal": subtotal,
"address_id": cart.AddressID,
@@ -94,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cart": cartResponse(r.Context(), cart),
})
}
@@ -157,7 +157,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
}
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cart": cartResponse(r.Context(), cart),
})
}
@@ -193,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address set",
})
}
@@ -238,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card set",
})
}
@@ -258,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "cart cleared",
})
}
@@ -386,7 +386,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
middleware.RecordCartConversion("validation_over_limit")
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{
map[string]interface{}{
"total_cents": total,
"limit_cents": h.maxOrderCents,
})
@@ -507,7 +507,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err
}
return tx.Model(cart).Updates(map[string]any{
return tx.Model(cart).Updates(map[string]interface{}{
"address_id": nil,
"card_id": nil,
}).Error
@@ -534,7 +534,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order": order,
})
}

View File

@@ -83,8 +83,8 @@ func TestGetCartEmpty(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []any `json:"items"`
Subtotal int `json:"subtotal"`
Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"`
} `json:"cart"`
} `json:"data"`
}
@@ -110,7 +110,7 @@ func TestSetItemAndGetCart(t *testing.T) {
handler := NewCartHandler("", "", 0)
// Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]any{
body, _ := json.Marshal(map[string]interface{}{
"coffee_id": 1,
"quantity": 2,
})
@@ -166,13 +166,13 @@ func TestSetItemUpdateQuantity(t *testing.T) {
handler := NewCartHandler("", "", 0)
// Add item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
// Update quantity
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder()
handler.SetItem(w, req)
@@ -211,13 +211,13 @@ func TestSetItemRemove(t *testing.T) {
handler := NewCartHandler("", "", 0)
// Add item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
// Remove item (quantity 0)
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0})
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder()
handler.SetItem(w, req)
@@ -231,7 +231,7 @@ func TestSetItemRemove(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []any `json:"items"`
Items []interface{} `json:"items"`
} `json:"cart"`
} `json:"data"`
}
@@ -249,7 +249,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -266,7 +266,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"quantity": 1})
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -285,7 +285,7 @@ func TestClearCart(t *testing.T) {
// Add two items
for _, coffeeID := range []uint{1, 2} {
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -309,7 +309,7 @@ func TestClearCart(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []any `json:"items"`
Items []interface{} `json:"items"`
} `json:"cart"`
} `json:"data"`
}
@@ -339,7 +339,7 @@ func TestSetAddress(t *testing.T) {
handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -393,7 +393,7 @@ func TestSetAddressWrongUser(t *testing.T) {
handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -421,7 +421,7 @@ func TestSetCard(t *testing.T) {
handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
@@ -473,7 +473,7 @@ func TestSetCardWrongUser(t *testing.T) {
handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
@@ -491,7 +491,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
handler := NewCartHandler("", "", 0)
// Add item but no address or card
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -515,14 +515,14 @@ func TestConvertCartMissingCard(t *testing.T) {
db := database.GetDB()
// Add item and address
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
db.Create(&addr)
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -563,7 +563,7 @@ func TestMultipleItems(t *testing.T) {
// Add three different items
for i := uint(1); i <= 3; i++ {
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -581,8 +581,8 @@ func TestMultipleItems(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []any `json:"items"`
Subtotal int `json:"subtotal"`
Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"`
} `json:"cart"`
} `json:"data"`
}
@@ -614,13 +614,13 @@ func TestCartIsolation(t *testing.T) {
r.Get("/cart", handler.GetCart)
// User 1 adds item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
req := authRequest("PUT", "/cart/item", body, user1.ID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// User 2 adds different item
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3})
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
req = authRequest("PUT", "/cart/item", body, user2.ID)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -709,17 +709,9 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("seed card: %v", err)
}
// Pin coffee_id=4's price so cart-cap math stays stable across
// seed-price changes. The cap test is about (price * quantity)
// vs cap, not which coffee or what the menu currently charges.
const pinnedPrice = 500
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", pinnedPrice).Error; err != nil {
t.Fatalf("pin coffee price: %v", err)
}
handler := NewCartHandler("", "", tc.capCents)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 4, "quantity": tc.quantity})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -728,7 +720,7 @@ func TestConvertCartLimit(t *testing.T) {
}
// set address
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -737,7 +729,7 @@ func TestConvertCartLimit(t *testing.T) {
}
// set card
body, _ = json.Marshal(map[string]any{"card_id": card.ID})
body, _ = json.Marshal(map[string]interface{}{"card_id": card.ID})
req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w = httptest.NewRecorder()
handler.SetCard(w, req)
@@ -753,9 +745,9 @@ func TestConvertCartLimit(t *testing.T) {
var resp struct {
Success bool `json:"success"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
@@ -768,8 +760,8 @@ func TestConvertCartLimit(t *testing.T) {
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
}
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*500 {
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*500, resp.Error.Details["total_cents"])
}
}
})
@@ -795,7 +787,7 @@ func TestSetCard_ExpiredReturns410(t *testing.T) {
}
handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
@@ -832,7 +824,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
db := database.GetDB()
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/vart/item", body, user.ID)
w := httptest.NewRecorder()
NewCartHandler("", "", 0).SetItem(w, req)
@@ -842,7 +834,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
City: "PDX", State: "OR", Zip: "97201", Country: "US",
}
db.Create(&addr)
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
NewCartHandler("", "", 0).SetAddress(w, req)

View File

@@ -221,7 +221,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"refund_id": rf.ID,
"status": "refunded",
})
@@ -287,7 +287,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order_id": order.ID,
"carrier": req.Carrier,
"tracking_number": req.TrackingNumber,

View File

@@ -30,7 +30,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"products": products,
})
}
@@ -53,7 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"product": product,
})
}

View File

@@ -49,7 +49,7 @@ func ReconcileOrders(stripeKey string) {
"order_id", order.ID, "pi", pi.ID)
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{
if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID,
}).Error; err != nil {
@@ -62,7 +62,7 @@ func ReconcileOrders(stripeKey string) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err
}
return tx.Model(&cart).Updates(map[string]any{
return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil,
"card_id": nil,
}).Error

View File

@@ -54,7 +54,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"user": user.ToPublic(),
"products": products,
"cart": cartItems,

View File

@@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
}
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{
if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID,
}).Error; err != nil {
@@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return fmt.Errorf("failed to delete cart item: %w", err)
}
return tx.Model(&cart).Updates(map[string]any{
return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil,
"card_id": nil,
}).Error

View File

@@ -104,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
// APIResponse represents the standard API response format
type APIResponse struct {
Success bool `json:"success"`
Data map[string]any `json:"data"`
Error *APIError `json:"error,omitempty"`
Success bool `json:"success"`
Data map[string]interface{} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
// APIError represents an error from the API
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
}
// ProductsResponse represents the products API response
@@ -339,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
body := map[string]any{
body := map[string]interface{}{
"coffee_id": coffeeID,
"quantity": quantity,
}
@@ -379,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
func (c *Client) SetCartAddress(addressID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
body := map[string]any{
body := map[string]interface{}{
"address_id": addressID,
}
jsonData, err := json.Marshal(body)
@@ -418,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
func (c *Client) SetCartCard(cardID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
body := map[string]any{
body := map[string]interface{}{
"card_id": cardID,
}
jsonData, err := json.Marshal(body)

View File

@@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string,
// ValidateToken validates a JWT token and returns the claims
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])

View File

@@ -4,9 +4,8 @@ import (
"fmt"
"log"
"terminalShop/pkg/models"
"gorm.io/gorm"
"terminalShop/pkg/models"
)
// Seed populates the database with initial data
@@ -28,7 +27,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 2,
BeanType: "Arabica",
Price: 1800, // $18.00
Price: 350, // $3.50
Color: "#8B4513",
Description: "Bold and intense coffee with a rich crema",
},
@@ -37,7 +36,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 8,
BeanType: "Arabica",
Price: 2200, // $22.00
Price: 400, // $4.00
Color: "#A0522D",
Description: "Smooth and balanced espresso diluted with hot water",
},
@@ -46,7 +45,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 6,
BeanType: "Arabica",
Price: 2200, // $22.00
Price: 450, // $4.50
Color: "#C4A574",
Description: "Espresso with steamed milk and a thick layer of foam",
},
@@ -55,7 +54,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 12,
BeanType: "Arabica",
Price: 2500, // $25.00
Price: 500, // $5.00
Color: "#D2B48C",
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
},
@@ -64,7 +63,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 12,
BeanType: "Arabica",
Price: 3000, // $30.00
Price: 550, // $5.50
Color: "#8B4513",
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
},
@@ -73,7 +72,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 3,
BeanType: "Robusta Blend",
Price: 1800, // $18.00
Price: 425, // $4.25
Color: "#DAA520",
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
},

View File

@@ -8,8 +8,8 @@ import (
// SuccessResponse represents a successful API response
type SuccessResponse struct {
Success bool `json:"success"`
Data any `json:"data"`
Success bool `json:"success"`
Data interface{} `json:"data"`
}
// ErrorResponse represents an error API response
@@ -20,13 +20,13 @@ type ErrorResponse struct {
// Error contains error details
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
}
// RespondSuccess sends a successful JSON response
func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
@@ -39,7 +39,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
}
// RespondError sends an error JSON response
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]any) {
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)