Compare commits
2 Commits
feat/sca-r
...
feat/refun
| Author | SHA1 | Date | |
|---|---|---|---|
| 74d6a9fdf2 | |||
| 3737eea7f6 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,9 +5,6 @@
|
||||
.env.example
|
||||
.env.prod
|
||||
|
||||
docs/sca-psd2-compliance.md
|
||||
accept-a-payment/
|
||||
|
||||
|
||||
FULFILLMENT.md
|
||||
.tmp-gocache/
|
||||
|
||||
@@ -24,12 +24,11 @@ import (
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
}
|
||||
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||
func NewCartHandler(stripeSecretKey string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey}
|
||||
}
|
||||
|
||||
// getOrCreateCart finds the users cart or creates one.
|
||||
@@ -396,7 +395,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
OffSession: stripe.Bool(true),
|
||||
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
||||
Enabled: stripe.Bool(true),
|
||||
AllowRedirects: stripe.String("never"),
|
||||
@@ -425,17 +423,13 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
stripeDur := time.Since(stripeStart).Seconds()
|
||||
if err != nil {
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
if stripeErr, ok := err.(*stripe.Error); ok {
|
||||
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
|
||||
h.respondRequiresAction(w, &order, stripeErr.PaymentIntent)
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
switch stripeErr.Type {
|
||||
case stripe.ErrorTypeCard:
|
||||
middleware.RecordCartConversion("card_declined")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
|
||||
case stripe.ErrorTypeInvalidRequest:
|
||||
middleware.RecordCartConversion("stripe_invalid_request")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
@@ -447,7 +441,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
middleware.RecordCartConversion("stripe_error")
|
||||
audit.OrderFailed(userID, order.ID, total, err.Error())
|
||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||
@@ -455,11 +448,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
||||
|
||||
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
|
||||
h.respondRequiresAction(w, &order, pi)
|
||||
return
|
||||
}
|
||||
|
||||
// Commit payment ID and clear cart in a single transaction.
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
order.StripePaymentID = pi.ID
|
||||
@@ -501,76 +489,3 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
"order": order,
|
||||
})
|
||||
}
|
||||
|
||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
|
||||
// The order stays pending. the webhook flips it to paid once the customer
|
||||
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
|
||||
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, pi *stripe.PaymentIntent) {
|
||||
db := database.GetDB()
|
||||
|
||||
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||
middleware.RecordCartConversion("sca_missing_next_action")
|
||||
db.Model(order).Update("status", models.OrderStatusFailed)
|
||||
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// persist the paymentIntent id before responding so the reconciliation jon
|
||||
// and webhook can match the eventual succeeded/failed event to this order
|
||||
// even if the customer drops off mid-auth
|
||||
if err := db.Model(order).Update("stripe_payment_id", pi.ID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
middleware.RecordCartConversion("requires_action")
|
||||
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"payment_intent_id": pi.ID,
|
||||
"status": "requires_action",
|
||||
"redirect_token": token,
|
||||
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||
})
|
||||
}
|
||||
|
||||
func mapStripeCardErrCode(code stripe.ErrorCode) string {
|
||||
switch code {
|
||||
case stripe.ErrorCodeAuthenticationRequired:
|
||||
return "AUTHENTICATION_REQUIRED"
|
||||
case "authentication_not_supported":
|
||||
return "AUTHENTICATION_NOT_SUPPORTED"
|
||||
case "card_declined", "":
|
||||
return "CARD_DECLINED"
|
||||
case "expired_card":
|
||||
return "CARD_EXPIRED"
|
||||
case "incorrect_cvc":
|
||||
return "CARD_CVC_FAILED"
|
||||
case "insufficient_funds":
|
||||
return "CARD_INSUFFICIENT_FUNDS"
|
||||
case "processing_error":
|
||||
return "CARD_PROCESSING_ERROR"
|
||||
}
|
||||
return "CARD_DECLINED"
|
||||
}
|
||||
|
||||
//nolint:unused // wired into Convert cart later
|
||||
func appURLForHandler(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := r.Host
|
||||
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
||||
host = fh
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add item (CoffeeID=1, Quantity=2)
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
||||
@@ -205,7 +205,7 @@ func TestSetItemRemove(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
||||
@@ -244,7 +244,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
@@ -261,7 +261,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
@@ -278,7 +278,7 @@ func TestClearCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add two items
|
||||
for _, coffeeID := range []uint{1, 2} {
|
||||
@@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
@@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
@@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
@@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
@@ -485,7 +485,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add item but no address or card
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||
@@ -508,7 +508,7 @@ func TestConvertCartMissingCard(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
db := database.GetDB()
|
||||
|
||||
// Add item and address
|
||||
@@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Empty cart convert
|
||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
@@ -556,7 +556,7 @@ func TestMultipleItems(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add three different items
|
||||
for i := uint(1); i <= 3; i++ {
|
||||
@@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) {
|
||||
}
|
||||
db.Create(&user2)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("")
|
||||
r := chi.NewRouter()
|
||||
r.Put("/cart/item", handler.SetItem)
|
||||
r.Get("/cart", handler.GetCart)
|
||||
|
||||
@@ -114,7 +114,7 @@ func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Reques
|
||||
text += "\n\n" + quoted
|
||||
}
|
||||
|
||||
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
|
||||
if err := notify.SlackPostToOrderThread(order.ID, text); err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
|
||||
return
|
||||
}
|
||||
@@ -145,29 +145,6 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"id": order.ID,
|
||||
"status": string(order.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// RefundOrder issues a full refund for an order via Stripe and marks it as
|
||||
// refunded. Only orders in paid or shipped status can be refunded.
|
||||
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -46,7 +46,7 @@ func SetupRoutes(
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
productHandler := handlers.NewProductHandler()
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||
@@ -126,7 +126,6 @@ func SetupRoutes(
|
||||
|
||||
// Orders
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
|
||||
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ type TokenResponse struct {
|
||||
}
|
||||
|
||||
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
||||
|
||||
reqBody := TokenRequest{
|
||||
@@ -496,40 +497,6 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
return &convertResp.Data.Order, nil
|
||||
}
|
||||
|
||||
type OrderStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrderStatus(id uint) (string, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch order status: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var sResp OrderStatusResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode order status response: %w", err)
|
||||
}
|
||||
if !sResp.Success {
|
||||
if sResp.Error != nil {
|
||||
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
|
||||
}
|
||||
return "", fmt.Errorf("failed to fetch order status")
|
||||
}
|
||||
return sResp.Data.Status, nil
|
||||
}
|
||||
|
||||
// GetCards fetches all saved cards for the user.
|
||||
func (c *Client) GetCards() ([]models.Card, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -22,7 +21,6 @@ const (
|
||||
eventOrderPaid eventType = "order_paid"
|
||||
eventOrderShipped eventType = "order_shipped"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRequiresAction eventType = "order_requires_action"
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
@@ -300,15 +298,3 @@ func attrsToMap(a []any) map[string]any {
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
//nolint:unused // called from ConvertCart in the next update
|
||||
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||
e := event{
|
||||
Event: eventOrderRequiresAction,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
StripeID: paymentIntentID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderRequiresAction, e)
|
||||
}
|
||||
|
||||
@@ -11,27 +11,10 @@ const (
|
||||
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
||||
DisplayKindSuccess DisplayKind = "success" // delivered
|
||||
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
||||
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
|
||||
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
|
||||
DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue
|
||||
)
|
||||
|
||||
// refundPending reports whether the order has an open customer refund request
|
||||
// that has not yet been resolved by a terminal status change.
|
||||
func (order *Order) refundPending() bool {
|
||||
if order.LastRefundRequestAt == nil {
|
||||
return false
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (order *Order) DisplayState() string {
|
||||
if order.refundPending() {
|
||||
return "Refund Requested"
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return "Awaiting payment"
|
||||
@@ -71,9 +54,6 @@ func (order *Order) DisplayState() string {
|
||||
}
|
||||
|
||||
func (order *Order) DisplayKind() DisplayKind {
|
||||
if order.refundPending() {
|
||||
return DisplayKindRefund
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return DisplayKindNeutral
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestOrderDisplayState(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
order Order
|
||||
@@ -27,10 +23,6 @@ func TestOrderDisplayState(t *testing.T) {
|
||||
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
||||
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
|
||||
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
|
||||
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
|
||||
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -191,17 +191,6 @@ func SlackUnpinOrder(orderID uint) {
|
||||
// It looks up the thread ts from the database and returns an error when the
|
||||
// reply cannot be posted.
|
||||
func SlackPostToOrderThread(orderID uint, text string) error {
|
||||
return slackPostToOrderThread(orderID, text, false)
|
||||
}
|
||||
|
||||
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
|
||||
// to the channel (reply_broadcast=true) so channel members get a normal
|
||||
// notification. Use for messages that need immediate operator attention.
|
||||
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
|
||||
return slackPostToOrderThread(orderID, text, true)
|
||||
}
|
||||
|
||||
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" {
|
||||
@@ -217,16 +206,11 @@ func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
|
||||
return fmt.Errorf("order has no slack thread")
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
if _, ok := postSlackMessage(token, map[string]any{
|
||||
"channel": channel,
|
||||
"thread_ts": *order.SlackThreadTS,
|
||||
"text": text,
|
||||
}
|
||||
if broadcast {
|
||||
payload["reply_broadcast"] = true
|
||||
}
|
||||
|
||||
if _, ok := postSlackMessage(token, payload); !ok {
|
||||
}); !ok {
|
||||
slog.Warn("failed to post slack thread reply", "order_id", orderID)
|
||||
return fmt.Errorf("failed to post slack thread reply")
|
||||
}
|
||||
|
||||
@@ -278,9 +278,10 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
|
||||
case models.DisplayKindError:
|
||||
return m.theme.TextError()
|
||||
case models.DisplayKindWarning:
|
||||
// !TODO add refunds and change this
|
||||
// No warning color in theme yet, reuse loading pink as visual flag
|
||||
// for now
|
||||
return m.theme.TextLoading()
|
||||
case models.DisplayKindRefund:
|
||||
return m.theme.TextBrand()
|
||||
}
|
||||
return m.theme.TextHighlight()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
@@ -193,7 +192,7 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.CheckingOut = false
|
||||
if msg.Err != nil {
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
||||
m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)}
|
||||
m.review.cardJustAdded = false
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
@@ -235,24 +234,3 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func friendlyCheckoutError(raw error) string {
|
||||
s := raw.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
|
||||
return "your bank wants to verify this charge, authentication step coming soon."
|
||||
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
||||
return "this card doesn't support online authentication. Try another card"
|
||||
case strings.Contains(s, "CARD_EXPIRED"):
|
||||
return "card expired. Add a new card and try again"
|
||||
case strings.Contains(s, "CARD_CVC_FAILED"):
|
||||
return "card cvc incorrect. Check the back of the card and try again"
|
||||
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
|
||||
return "insufficient funds on this card"
|
||||
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
|
||||
return "card processing error. Try again in a moment"
|
||||
case strings.Contains(s, "CARD_DECLINED"):
|
||||
return "card declined"
|
||||
}
|
||||
return "checkout failed, try again."
|
||||
}
|
||||
|
||||
@@ -27,15 +27,6 @@ const (
|
||||
refundTextareaHPad = 4
|
||||
)
|
||||
|
||||
// Nerd-font glyphs used in the refund composer. Requires a Nerd Font in the
|
||||
// SSH client terminal; renders as tofu otherwise.
|
||||
const (
|
||||
refundIconUndo = "\uf0e2" // nf-fa-undo
|
||||
refundIconTag = "\uf02b" // nf-oct-tag
|
||||
refundIconComment = "\uf41f" // nf-oct-comment
|
||||
refundIconKey = "\uf11c" // nf-fa-keyboard_o
|
||||
)
|
||||
|
||||
// refundFocus identifies which field in the refund composer has keyboard focus.
|
||||
type refundFocus int
|
||||
|
||||
@@ -170,23 +161,22 @@ func (m Model) buildRefundForm(state *refundState) *huh.Form {
|
||||
func (m Model) newRefundTextarea() textarea.Model {
|
||||
ta := textarea.New()
|
||||
ta.Prompt = ""
|
||||
ta.Placeholder = "Describe the issue"
|
||||
ta.Placeholder = "Describe the issue (optional)..."
|
||||
ta.ShowLineNumbers = true
|
||||
ta.CharLimit = 65536
|
||||
ta.DynamicHeight = false
|
||||
ta.SetHeight(m.refundTextareaHeight())
|
||||
ta.DynamicHeight = true
|
||||
ta.MinHeight = refundMinTextareaRows
|
||||
ta.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||
ta.SetHeight(refundMinTextareaRows)
|
||||
ta.SetWidth(m.refundTextareaWidth())
|
||||
|
||||
base := lipgloss.NewStyle()
|
||||
// Cursor-line uses a darker grey than theme.Border() so the active line
|
||||
// reads as a subtle wash, not a bright bar competing with the highlight.
|
||||
cursorLineBG := lipgloss.Color("236")
|
||||
ta.SetStyles(textarea.Styles{
|
||||
Focused: textarea.StyleState{
|
||||
Base: base,
|
||||
Text: base.Foreground(m.theme.Accent()),
|
||||
LineNumber: base.Foreground(m.theme.Dim()),
|
||||
CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()),
|
||||
CursorLine: base.Background(m.theme.Border()).Foreground(m.theme.Accent()),
|
||||
CursorLineNumber: base.Foreground(m.theme.Body()),
|
||||
Placeholder: base.Foreground(m.theme.Dim()),
|
||||
EndOfBuffer: base.Foreground(m.theme.Dim()),
|
||||
@@ -210,7 +200,8 @@ func (m Model) newRefundTextarea() textarea.Model {
|
||||
}
|
||||
|
||||
func (m Model) resizeRefundTextarea(state *refundState) {
|
||||
state.textarea.SetHeight(m.refundTextareaHeight())
|
||||
state.textarea.MinHeight = refundMinTextareaRows
|
||||
state.textarea.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||
state.textarea.SetWidth(m.refundTextareaWidth())
|
||||
}
|
||||
|
||||
@@ -260,19 +251,14 @@ func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, m
|
||||
}
|
||||
|
||||
// RenderRefundOverlay renders the refund composer as a centered modal.
|
||||
// The modal has no border or background fill — only padding for breathing
|
||||
// room. lipgloss.Place handles centering against the container.
|
||||
func (m Model) RenderRefundOverlay() string {
|
||||
state := &m.refund
|
||||
contentWidth := m.refundContentWidth()
|
||||
|
||||
titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID)
|
||||
title := m.theme.TextAccent().
|
||||
Bold(true).
|
||||
Width(contentWidth).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(m.theme.Highlight()).
|
||||
Render(titleText)
|
||||
Render(fmt.Sprintf("Refund request - Order #%d", state.orderID))
|
||||
|
||||
body := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
@@ -286,6 +272,9 @@ func (m Model) RenderRefundOverlay() string {
|
||||
)
|
||||
|
||||
modal := lipgloss.NewStyle().
|
||||
Width(contentWidth).
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(m.theme.Border()).
|
||||
Padding(1, 2).
|
||||
Render(body)
|
||||
|
||||
@@ -306,7 +295,7 @@ func (m Model) renderRefundReason(state *refundState) string {
|
||||
value = "Select a reason"
|
||||
}
|
||||
|
||||
label := m.theme.TextLabel().Render(refundIconTag + " Reason")
|
||||
label := m.theme.TextLabel().Render("Reason")
|
||||
selected := m.theme.TextAccent().
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
PaddingLeft(1).
|
||||
@@ -322,7 +311,7 @@ func (m Model) renderRefundReason(state *refundState) string {
|
||||
|
||||
func (m Model) renderRefundTextarea(state *refundState) string {
|
||||
label := "Message (optional)"
|
||||
placeholder := "Describe the issue"
|
||||
placeholder := "Describe the issue (optional)..."
|
||||
if state.reason == models.RefundRequestReasonOther {
|
||||
label = "Message (required)"
|
||||
placeholder = "Describe the issue..."
|
||||
@@ -334,26 +323,22 @@ func (m Model) renderRefundTextarea(state *refundState) string {
|
||||
border = m.theme.Highlight()
|
||||
}
|
||||
|
||||
wrapper := lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(border)
|
||||
|
||||
contentWidth := m.refundContentWidth()
|
||||
state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize())
|
||||
state.textarea.SetHeight(m.refundTextareaHeight())
|
||||
|
||||
textareaBox := wrapper.Render(state.textarea.View())
|
||||
|
||||
counter := m.theme.TextDim().
|
||||
Width(contentWidth).
|
||||
Width(m.refundTextareaWidth()).
|
||||
Align(lipgloss.Right).
|
||||
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
|
||||
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(border).
|
||||
Padding(0, 1).
|
||||
Width(m.refundContentWidth()).
|
||||
Render(state.textarea.View() + "\n" + counter)
|
||||
|
||||
out := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
m.theme.TextLabel().Render(refundIconComment+" "+label),
|
||||
textareaBox,
|
||||
counter,
|
||||
m.theme.TextLabel().Render(label),
|
||||
box,
|
||||
)
|
||||
if state.messageErr != "" {
|
||||
out += "\n" + m.theme.TextError().Render(state.messageErr)
|
||||
@@ -368,14 +353,7 @@ func (m Model) renderRefundFooter(state *refundState) string {
|
||||
if state.err != "" {
|
||||
return m.theme.TextError().Render(state.err)
|
||||
}
|
||||
key := m.theme.TextHighlight().Bold(true)
|
||||
sep := m.theme.TextDim().Render(" · ")
|
||||
dim := m.theme.TextDim()
|
||||
return dim.Render(refundIconKey+" ") +
|
||||
key.Render("tab") + dim.Render(" next") + sep +
|
||||
key.Render("shift+tab") + dim.Render(" prev") + sep +
|
||||
key.Render("ctrl+s") + dim.Render(" submit") + sep +
|
||||
key.Render("esc") + dim.Render(" cancel")
|
||||
return m.theme.TextDim().Render("tab next field | shift+tab prev | ctrl+s submit | esc cancel")
|
||||
}
|
||||
|
||||
func (m Model) refundContentWidth() int {
|
||||
@@ -389,7 +367,3 @@ func (m Model) refundContentWidth() int {
|
||||
func (m Model) refundTextareaWidth() int {
|
||||
return max(1, m.refundContentWidth()-refundTextareaHPad)
|
||||
}
|
||||
|
||||
func (m Model) refundTextareaHeight() int {
|
||||
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||
}
|
||||
|
||||
@@ -82,10 +82,6 @@ func (m Model) renderString() string {
|
||||
content = loadingStyle.Render("Loading products from API...")
|
||||
} else if m.error != nil {
|
||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||
// soft-wrap long messages instead of clipping.
|
||||
if m.widthContent > 4 {
|
||||
errorStyle = errorStyle.Width(m.widthContent - 4)
|
||||
}
|
||||
errorBanner := errorStyle.Render(m.error.message)
|
||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
} else if m.notice != nil {
|
||||
|
||||
Reference in New Issue
Block a user