Compare commits

...

8 Commits

Author SHA1 Message Date
9797bc59d1 feat(notify): daily slack reminder for paid orders awaiting labels
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 10:59:21 +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
29 changed files with 330 additions and 181 deletions

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: CI
on:
pull_request:
branches: [master]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Format check
run: |
unformatted=$(gofmt -l $(go list -f '{{.Dir}}' ./...))
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.4.0
- name: Test (race detector)
run: go test -race ./...

16
.golangci.yml Normal file
View File

@@ -0,0 +1,16 @@
version: "2"
run:
timeout: 5m
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
exclusions:
paths:
- terminal-shop-source

View File

@@ -13,7 +13,7 @@ import (
func setupAddressTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_addresses.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) {
func TestGetAddressesEmpty(t *testing.T) {
testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewAddressHandler("", "", "")
@@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) {
func TestGetAddressesIsolation(t *testing.T) {
testDB, user1 := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) {
Addresses []models.Address `json:"addresses"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Addresses) != 1 {
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses))
@@ -117,7 +117,7 @@ func TestGetAddressesIsolation(t *testing.T) {
func TestGetAddressesWithData(t *testing.T) {
testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()

View File

@@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret"
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
t.Helper()
testDB := "test_auth.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User {
func TestGetTokenSuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
user := createAuthTestUser(t, "SHA256:testfingerprint123")
@@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) {
func TestGetTokenBadSecret(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) {
func TestGetTokenMissingFingerprint(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) {
func TestGetTokenUnknownFingerprint(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) {
func TestGetTokenInvalidJSON(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json"))
@@ -185,7 +185,7 @@ func TestGetTokenInvalidJSON(t *testing.T) {
func TestRegisterWithSSHKeySuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
createAuthTestUser(t, "SHA256:existingfp")
@@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
func TestGetUserBySSHKeySuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
createAuthTestUser(t, "SHA256:lookupfp")
@@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) {
func TestGetUserBySSHKeyNotFound(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil)
@@ -282,7 +282,7 @@ func TestGetUserBySSHKeyNotFound(t *testing.T) {
func TestGetUserBySSHKeyMissingParam(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)

View File

@@ -30,14 +30,16 @@ var (
payRedirectsMu sync.Mutex
)
func storeRedirect(stripeURL string) string {
func storeRedirect(stripeURL string) (string, error) {
b := make([]byte, 16)
rand.Read(b)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
payRedirectsMu.Lock()
payRedirects[token] = stripeURL
payRedirectsMu.Unlock()
return token
return token, nil
}
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
@@ -166,7 +168,11 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return
}
token := storeRedirect(sess.URL)
token, err := storeRedirect(sess.URL)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})

View File

@@ -20,7 +20,7 @@ import (
func setupCartTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_cart.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -62,7 +62,7 @@ func authRequest(method, url string, body []byte, userID uint) *http.Request {
func TestGetCartEmpty(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -101,7 +101,7 @@ func TestGetCartEmpty(t *testing.T) {
func TestSetItemAndGetCart(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -157,7 +157,7 @@ func TestSetItemAndGetCart(t *testing.T) {
func TestSetItemUpdateQuantity(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -190,7 +190,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items))
@@ -202,7 +202,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
func TestSetItemRemove(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -232,7 +232,7 @@ func TestSetItemRemove(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 0 {
t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items))
@@ -241,7 +241,7 @@ func TestSetItemRemove(t *testing.T) {
func TestSetItemInvalidProduct(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -258,7 +258,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
func TestSetItemMissingCoffeeID(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -275,7 +275,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
func TestClearCart(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -310,7 +310,7 @@ func TestClearCart(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 0 {
t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items))
@@ -319,7 +319,7 @@ func TestClearCart(t *testing.T) {
func TestSetAddress(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -358,7 +358,7 @@ func TestSetAddress(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID {
t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID)
@@ -367,7 +367,7 @@ func TestSetAddress(t *testing.T) {
func TestSetAddressWrongUser(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -402,7 +402,7 @@ func TestSetAddressWrongUser(t *testing.T) {
func TestSetCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -440,7 +440,7 @@ func TestSetCard(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID {
t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID)
@@ -449,7 +449,7 @@ func TestSetCard(t *testing.T) {
func TestSetCardWrongUser(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -482,7 +482,7 @@ func TestSetCardWrongUser(t *testing.T) {
func TestConvertCartMissingAddress(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -505,7 +505,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
func TestConvertCartMissingCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -536,7 +536,7 @@ func TestConvertCartMissingCard(t *testing.T) {
func TestConvertCartEmpty(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -553,7 +553,7 @@ func TestConvertCartEmpty(t *testing.T) {
func TestMultipleItems(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -583,7 +583,7 @@ func TestMultipleItems(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 3 {
t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items))
@@ -595,7 +595,7 @@ func TestMultipleItems(t *testing.T) {
func TestCartIsolation(t *testing.T) {
testDB, user1 := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -638,7 +638,7 @@ func TestCartIsolation(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items))
@@ -655,7 +655,7 @@ func TestCartIsolation(t *testing.T) {
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items))

View File

@@ -5,11 +5,12 @@ import (
"net/http"
"os"
"strconv"
"time"
"terminalShop/pkg/bring"
"terminalShop/pkg/notify"
"terminalShop/pkg/shipping"
"terminalShop/pkg/shippo"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
@@ -20,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
"gorm.io/gorm"
)
type OrderHandler struct {
@@ -43,7 +45,10 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
db.Where("user_id = ?", userID).
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"orders": orders,

View File

@@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string {
testDB := "test_products.db"
// Remove if exists
os.Remove(testDB)
_ = os.Remove(testDB)
// Reset singleton
database.ResetForTesting()
@@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string {
func TestGetProduct(t *testing.T) {
testDB := setupTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewProductHandler()
@@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) {
func TestGetProductNotFound(t *testing.T) {
testDB := setupTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewProductHandler()
@@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) {
func TestGetProductsEmptyDatabase(t *testing.T) {
testDB := "test_empty.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
// Create database but don't seed

View File

@@ -7,6 +7,7 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
@@ -75,3 +76,26 @@ func ReconcileOrders(stripeKey string) {
}
}
}
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() {
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour)
err := db.Where(
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
models.OrderStatusPaid, cutoff,
).Order("created_at ASC").Find(&orders).Error
if err != nil {
reconcileLog().Error("unshipped query failed", "error", err)
return
}
if len(orders) == 0 {
return
}
reconcileLog().Info("unshipped orders found", "count", len(orders))
notify.SlackUnshippedReminder(orders)
}

View File

@@ -133,7 +133,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result struct {
Data struct {
@@ -201,7 +201,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
return
@@ -245,7 +245,7 @@ func (h *SlackHandler) editClearActions(responseURL, status, actor string) {
slog.Warn("slack mark_status: response_url edit failed", "error", err)
return
}
resp.Body.Close()
_ = resp.Body.Close()
}
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
@@ -264,7 +264,7 @@ func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
}
mac := hmac.New(sha256.New, []byte(h.signingSecret))
fmt.Fprintf(mac, "v0:%d:%s", ts, body)
_, _ = fmt.Fprintf(mac, "v0:%d:%s", ts, body)
expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sigHeader))

View File

@@ -7,6 +7,8 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"gorm.io/gorm"
)
type ViewHandler struct{}
@@ -38,7 +40,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("user_id = ?", userID).Find(&cards)
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders)
db.Where("user_id = ?", userID).
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at DESC").Find(&orders)
var cartItems []models.CartItem
if cart.ID != 0 {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)

View File

@@ -40,7 +40,8 @@ func main() {
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
defer func() { _ = database.Close() }()
// Run migrations
if err := database.Migrate(db); err != nil {
@@ -61,7 +62,7 @@ func main() {
if err != nil {
log.Printf("tracing init failed (non-fatal): %v", err)
} else {
defer shutdown(context.Background())
defer func() { _ = shutdown(context.Background()) }()
}
stripeclient.InitOTel()
@@ -112,6 +113,21 @@ func main() {
}
}()
go func() {
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received

View File

@@ -59,7 +59,7 @@ func SetupRoutes(
r.Get("/t/{id}", handlers.TrackingRedirect)
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
})
r.Handle("/metrics", middleware.MetricsHandler())

View File

@@ -80,7 +80,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
if err != nil {
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
@@ -137,7 +137,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch products: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -167,7 +167,7 @@ func (c *Client) Health() error {
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -217,7 +217,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
if err != nil {
return nil, fmt.Errorf("failed to register user: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var registerResp RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil {
@@ -302,7 +302,7 @@ func (c *Client) GetCart() (*CartData, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
@@ -342,7 +342,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
if err != nil {
return nil, fmt.Errorf("failed to set cart item: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
@@ -381,7 +381,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
if err != nil {
return fmt.Errorf("failed to set cart address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -420,7 +420,7 @@ func (c *Client) SetCartCard(cardID uint) error {
if err != nil {
return fmt.Errorf("failed to set cart card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -450,7 +450,7 @@ func (c *Client) ClearCart() error {
if err != nil {
return fmt.Errorf("failed to clear cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -480,7 +480,7 @@ func (c *Client) ConvertCart() (*models.Order, error) {
if err != nil {
return nil, fmt.Errorf("failed to convert cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var convertResp ConvertCartResponse
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
@@ -510,7 +510,7 @@ func (c *Client) GetCards() ([]models.Card, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch cards: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cardsResp CardsResponse
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
@@ -553,7 +553,7 @@ func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) {
if err != nil {
return nil, fmt.Errorf("failed to save card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cardResp CardResponse
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
@@ -590,7 +590,7 @@ func (c *Client) CollectCard() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to collect card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
@@ -619,7 +619,7 @@ func (c *Client) DeleteCard(id uint) error {
if err != nil {
return fmt.Errorf("failed to delete card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
@@ -648,7 +648,7 @@ func (c *Client) GetOrders() ([]models.Order, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch orders: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var ordersResp OrderResponse
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil {
@@ -686,7 +686,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) {
return nil, fmt.Errorf("failed to fetch addresses: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var addrResp AddressResponse
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
@@ -732,7 +732,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e
if err != nil {
return nil, fmt.Errorf("failed to create address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result struct {
Success bool `json:"success"`
@@ -769,7 +769,7 @@ func (c *Client) DeleteAddress(id uint) error {
if err != nil {
return fmt.Errorf("failed to delete address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
@@ -804,7 +804,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) {
if err != nil {
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
}

View File

@@ -17,7 +17,7 @@ import (
func setupAuthTestDB(t *testing.T) string {
t.Helper()
testDB := "test_ssh_auth.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey {
func TestAuthenticateSSHKeyNewUser(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) {
func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
func TestRegisterWithSSHKey(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestIsSSHKeyRegistered(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) {
}
// After authentication (which auto-creates)
svc.AuthenticateSSHKey(key)
_, _, _ = svc.AuthenticateSSHKey(key)
registered, err = svc.IsSSHKeyRegistered(key)
if err != nil {

View File

@@ -197,7 +197,7 @@ func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out an
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)

View File

@@ -110,7 +110,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e
if err != nil {
return nil, fmt.Errorf("bring request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {

View File

@@ -101,7 +101,7 @@ func Close() error {
// WARNING: Only use this in tests!
func ResetForTesting() {
if db != nil {
Close()
_ = Close()
db = nil
once = sync.Once{}
}

View File

@@ -10,7 +10,7 @@ import (
func TestConnect(t *testing.T) {
// Use a temporary test database
testDB := "test_terminalshop.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -35,7 +35,7 @@ func TestConnect(t *testing.T) {
func TestMigrate(t *testing.T) {
testDB := "test_migrate.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) {
func TestSeed(t *testing.T) {
testDB := "test_seed.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -115,7 +115,7 @@ func TestSeed(t *testing.T) {
func TestGetDB(t *testing.T) {
testDB := "test_getdb.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
// Initialize connection

View File

@@ -71,9 +71,10 @@ type Order struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
}
// TableName specifies the table name for the Order model.

View File

@@ -1,6 +1,9 @@
package models
import "time"
import (
"encoding/json"
"time"
)
type OrderEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -14,3 +17,47 @@ type OrderEvent struct {
func (OrderEvent) TableName() string {
return "order_events"
}
func (e *OrderEvent) PayloadMap() map[string]any {
if e.Payload == "" {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
return nil
}
return m
}
func (e *OrderEvent) DisplayLabel() (string, bool) {
switch e.Type {
case "order_paid":
return "Paid", true
case "label_purchased":
return "Label created", true
case "order_delivered":
return "Delivered", true
case "order_refunded":
return "Refunded", true
case "order_failed":
return "Payment failed", true
case "tracking_updated":
status, _ := e.PayloadMap()["status"].(string)
return trackingLabel(status)
}
return "", false
}
func trackingLabel(status string) (string, bool) {
switch status {
case "transit":
return "In transit", true
case "failure":
return "Delivery issue", true
case "returned":
return "Returned to sender", true
case "pre_transit":
return "Label scanned by carrier", true
}
return "", false
}

View File

@@ -1,3 +1,4 @@
// Package notify provides a simple interface to send notifications to Slack.
package notify
import (
@@ -14,8 +15,10 @@ import (
"terminalShop/pkg/models"
)
// slack client
var slackClient = &http.Client{Timeout: 5 * time.Second}
// dollars converts cents to dollars
func dollars(cents int) string {
return fmt.Sprintf("$%.2f", float64(cents)/100)
}
@@ -32,7 +35,7 @@ func postSlackMessage(token string, payload map[string]any) (string, bool) {
slog.Warn("slack post failed", "error", err)
return "", false
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var parsed struct {
Ok bool `json:"ok"`
@@ -66,7 +69,7 @@ func slackPinCall(token, endpoint, channel, ts, benignErr string) bool {
slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err)
return false
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var parsed struct {
Ok bool `json:"ok"`
@@ -261,4 +264,44 @@ func markStatusActionBlock(orderID uint) map[string]any {
}
}
var _ = time.Second
func SlackUnshippedReminder(orders []models.Order) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" || len(orders) == 0 {
return
}
workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN")
lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))}
for _, order := range orders {
lines = append(lines, " "+unshippedOrderLine(order, channel, workspace))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"text": strings.Join(lines, "\n"),
})
}
func unshippedOrderLine(order models.Order, channel, workspace string) string {
age := time.Since(order.CreatedAt).Round(time.Hour)
location := order.ShippingCountry
if order.ShippingCity != "" {
location = order.ShippingCity + ", " + order.ShippingCountry
}
label := fmt.Sprintf("Order #%d", order.ID)
if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" {
ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "")
permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts)
label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID)
}
tag := ""
if order.ShippingCountry == "NO" {
tag = " _(NO * Bring blocked)_"
}
return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag)
}

View File

@@ -270,7 +270,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body any, out
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
@@ -432,7 +432,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e
if err != nil {
return nil, fmt.Errorf("shippo request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {

View File

@@ -324,7 +324,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch selectedItem {
case "order history", "active orders":
if len(m.currentOrderList()) > 0 {
if m.account.orderViewState == 0 {
switch m.account.orderViewState {
case 0:
m.account.orderViewState = 1
m.account.orderCursor = 0
m.account.detailViewport.GotoTop()
@@ -334,8 +335,7 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
} else if m.account.orderViewState == 1 {
// Save scroll position before entering detail view
case 1:
m.account.orderYOffset = m.account.detailViewport.YOffset
m.account.orderViewState = 2
m.account.detailViewport.GotoTop()

View File

@@ -201,6 +201,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
}
}
visible := make([]models.OrderEvent, 0, len(order.Events))
for _, event := range order.Events {
if _, ok := event.DisplayLabel(); ok {
visible = append(visible, event)
}
}
if len(visible) > 0 {
b.WriteString(labelStyle.Render("Timeline"))
b.WriteString("\n")
for _, event := range visible {
label, _ := event.DisplayLabel()
when := event.CreatedAt.Format("Jan 02 3:04 PM")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
b.WriteString("\n")
}
b.WriteString("\n")
}
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")
@@ -248,4 +266,3 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
}
return m.theme.TextHighlight()
}

View File

@@ -145,18 +145,18 @@ func (m Model) generateReviewContent() string {
if m.ShippingInfo.Street2 != "" {
view.WriteString(m.ShippingInfo.Street2 + "\n")
}
view.WriteString(fmt.Sprintf("%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country))
fmt.Fprintf(&view, "%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)
}
view.WriteString("\n")
if m.SelectedCard != nil {
view.WriteString(fmt.Sprintf("cc: **** **** **** %s\n", m.SelectedCard.Last4))
fmt.Fprintf(&view, "cc: **** **** **** %s\n", m.SelectedCard.Last4)
}
view.WriteString("\n")
subtotal := m.CalculateSubtotal()
view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal)))
fmt.Fprintf(&view, "subtotal: %s\n", formatUSD(subtotal))
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
view.WriteString("\n")
view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n")

View File

@@ -400,23 +400,6 @@ func (m Model) fetchCartCmd() tea.Msg {
return CartFetchedMsg{Cart: cart, Err: err}
}
// fetchProductsCmd fetches products from the API
func (m Model) fetchProductsCmd() tea.Msg {
if m.APIClient == nil {
log.Println("[TUI] APIClient is nil, using fallback products")
return ProductsMsg{Err: nil} // No API client, use fallback
}
log.Printf("[TUI] Fetching products from API at %s", m.APIClient.BaseURL)
products, err := m.APIClient.GetProducts()
if err != nil {
log.Printf("[TUI] Failed to fetch products: %v", err)
} else {
log.Printf("[TUI] Loaded %d products from API", len(products))
}
return ProductsMsg{Products: products, Err: err}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.authCmd,
@@ -670,58 +653,6 @@ func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, 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")}
}
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 {
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)}
}
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
if err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)}
}
if err := m.resolveCartAddress(); err != nil {
return CheckoutResultMsg{Err: err}
}
// 3. Set the card on the cart
if err := m.APIClient.SetCartCard(card.ID); err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)}
}
// 4. Convert the cart to an order
order, err := m.APIClient.ConvertCart()
if err != nil {
return CheckoutResultMsg{Err: err}
}
return CheckoutResultMsg{
OrderID: order.ID,
Total: order.Total,
}
}
}
func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {

View File

@@ -35,7 +35,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
Data: data,
}
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}
// RespondError sends an error JSON response
@@ -52,7 +52,7 @@ func RespondError(w http.ResponseWriter, statusCode int, code, message string, d
},
}
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}
// HealthData represents health check data

View File

@@ -66,7 +66,7 @@ func CcnValidator(cardNumber string) error {
func IsDigits(name string) ErrorHandler {
return func(str string) error {
for _, c := range str {
if !(c >= '0' && c <= '9') {
if c < '0' || c > '9' {
return fmt.Errorf("[%s] expected only digits but got %s", name, str)
}
}