mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
rework: added shipping validation at address creation
the last implementation just accepted any input for the shipping validation and then only validated the address on payment. this is now changed to validation upon creation upon finishing the shipping address form. when a user completes the shipping form, a client-side check is first made to just simply check if there are any empty fields, if this check passes it gets check server-side by shippo. If the address is not found it will return an address not found error, and the user will not be able to continue to the payment form. The invalid address will also not be saved to the database. if the check pass, we simply save the address to the database with an ID. this ID is then passe in when doing the payment, instead of validating at the payment phase. this implementation currently only uses Shippo, which i have come to realize does NOT support shipping in countries outside of the US (i should have read the friendly manual before using Shippo). This means that a huge rewrite is due. i will focus on adding address validation for Norway first since thats where i live. i plan on using Bring's api for this validation. i will still keep Shippo for US based address verification even though this project is focused fon nordic shipping - just for a rainy day if some poor soul decides to order from AMERICA
This commit is contained in:
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -10,14 +11,17 @@ import (
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/shippo"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
type AddressHandler struct{}
|
||||
type AddressHandler struct {
|
||||
shippoKey string
|
||||
}
|
||||
|
||||
// Create a new AddressHandler
|
||||
func NewAddressHandler() *AddressHandler {
|
||||
return &AddressHandler{}
|
||||
// NewAddressHandler creates a new AddressHandler with the Shippo API key
|
||||
func NewAddressHandler(shippoAPIKey string) *AddressHandler {
|
||||
return &AddressHandler{shippoKey: shippoAPIKey}
|
||||
}
|
||||
|
||||
// Retrieve all saved addresses for a user
|
||||
@@ -60,16 +64,48 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
address := models.Address{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Street: req.Street,
|
||||
Street2: req.Street2,
|
||||
City: req.City,
|
||||
State: req.State,
|
||||
Zip: req.Zip,
|
||||
Country: req.Country,
|
||||
Phone: req.Phone,
|
||||
// Validate and normalize address via Shippo
|
||||
var address models.Address
|
||||
if h.shippoKey != "" {
|
||||
client := shippo.NewClient(h.shippoKey)
|
||||
validated, err := client.ValidateAddress(shippo.Address{
|
||||
Name: req.Name,
|
||||
Street1: req.Street,
|
||||
Street2: req.Street2,
|
||||
City: req.City,
|
||||
State: req.State,
|
||||
Country: req.Country,
|
||||
Zip: req.Zip,
|
||||
Phone: req.Phone,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("shippo address validation failed: %v", err)
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
|
||||
return
|
||||
}
|
||||
address = models.Address{
|
||||
UserID: userID,
|
||||
Name: validated.Name,
|
||||
Street: validated.Street1,
|
||||
Street2: validated.Street2,
|
||||
City: validated.City,
|
||||
State: validated.State,
|
||||
Zip: validated.Zip,
|
||||
Country: validated.Country,
|
||||
Phone: validated.Phone,
|
||||
}
|
||||
} else {
|
||||
address = models.Address{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Street: req.Street,
|
||||
Street2: req.Street2,
|
||||
City: req.City,
|
||||
State: req.State,
|
||||
Zip: req.Zip,
|
||||
Country: req.Country,
|
||||
Phone: req.Phone,
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Create(&address).Error; err != nil {
|
||||
@@ -82,7 +118,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Delete a saved address
|
||||
// DeleteAddress deltes a saved address
|
||||
func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
@@ -3,6 +3,8 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
@@ -31,20 +33,13 @@ type CheckoutCartItem struct {
|
||||
}
|
||||
|
||||
type CheckoutRequest struct {
|
||||
StripeToken string `json:"stripe_token"`
|
||||
Last4 string `json:"last4"`
|
||||
Brand string `json:"brand"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Items []CheckoutCartItem `json:"items"`
|
||||
ShippingName string `json:"shipping_name"`
|
||||
ShippingStreet string `json:"shipping_street"`
|
||||
ShippingStreet2 string `json:"shipping_street2"`
|
||||
ShippingCity string `json:"shipping_city"`
|
||||
ShippingState string `json:"shipping_state"`
|
||||
ShippingZip string `json:"shipping_zip"`
|
||||
ShippingCountry string `json:"shipping_country"`
|
||||
ShippingPhone string `json:"shipping_phone"`
|
||||
StripeToken string `json:"stripe_token"`
|
||||
Last4 string `json:"last4"`
|
||||
Brand string `json:"brand"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Items []CheckoutCartItem `json:"items"`
|
||||
AddressID uint `json:"address_id"`
|
||||
}
|
||||
|
||||
func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -67,6 +62,17 @@ func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.AddressID == 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address_id is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var address models.Address
|
||||
if err := db.Where("id = ? AND user_id = ?", req.AddressID, userID).First(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
@@ -92,20 +98,27 @@ func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
user.StripeCustomerID = cust.ID
|
||||
db.Save(&user)
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save stripe customer", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var subtotal int
|
||||
var orderItems []models.OrderItem
|
||||
|
||||
for _, item := range req.Items {
|
||||
if item.Quantity <= 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "item quantity must be at least 1", nil)
|
||||
return
|
||||
}
|
||||
var coffee models.Coffee
|
||||
if err := db.First(&coffee, item.CoffeeID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_PRODUCT", fmt.Sprintf("product %d not found", item.CoffeeID), nil)
|
||||
return
|
||||
}
|
||||
|
||||
priceInCents := int(coffee.Price * 100)
|
||||
priceInCents := int(math.Round(coffee.Price * 100))
|
||||
lineTotal := priceInCents * item.Quantity
|
||||
subtotal += lineTotal
|
||||
|
||||
@@ -135,7 +148,8 @@ func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ch, err := charge.New(chargeParams)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", fmt.Sprintf("payment failed: %v", err), nil)
|
||||
log.Printf("stripe charge failed for user %d: %v", user.ID, err)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", "payment could not be processed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,7 +162,10 @@ func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
ExpYear: req.ExpYear,
|
||||
}
|
||||
|
||||
db.Create(&card)
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
@@ -157,14 +174,14 @@ func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
Status: models.OrderStatusPaid,
|
||||
Subtotal: subtotal,
|
||||
Total: total,
|
||||
ShippingName: req.ShippingName,
|
||||
ShippingStreet: req.ShippingStreet,
|
||||
ShippingStreet2: req.ShippingStreet2,
|
||||
ShippingCity: req.ShippingCity,
|
||||
ShippingState: req.ShippingState,
|
||||
ShippingZip: req.ShippingZip,
|
||||
ShippingCountry: req.ShippingCountry,
|
||||
ShippingPhone: req.ShippingPhone,
|
||||
ShippingName: address.Name,
|
||||
ShippingStreet: address.Street,
|
||||
ShippingStreet2: address.Street2,
|
||||
ShippingCity: address.City,
|
||||
ShippingState: address.State,
|
||||
ShippingZip: address.Zip,
|
||||
ShippingCountry: address.Country,
|
||||
ShippingPhone: address.Phone,
|
||||
Items: orderItems,
|
||||
}
|
||||
|
||||
@@ -173,7 +190,10 @@ func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
db.Preload("Items").First(&order, order.ID)
|
||||
if err := db.Preload("Items").First(&order, order.ID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to reload order", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
"order": order,
|
||||
|
||||
@@ -46,7 +46,7 @@ func main() {
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
// Setup routes
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey, jwtManager, cfg.AuthFingerprintKey)
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
|
||||
@@ -14,15 +14,15 @@ type contextKey string
|
||||
const userIDKey contextKey = "userID"
|
||||
|
||||
func Auth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler {
|
||||
// Auth validates JWT tokens on all requests.
|
||||
// if a valid token is present, store an userID in context
|
||||
// if no token, continue as public (no userID).
|
||||
// If invalid token, returns 401
|
||||
// Auth extracts and validates JWT tokens on all requests.
|
||||
// If a valid token is present, store the userID in context.
|
||||
// If no token or an invalid token, continue as public (no userID).
|
||||
// Protected routes use RequireAuth to enforce valid authentication.
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// No token - public req
|
||||
// No token - public request
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -30,13 +30,16 @@ func Auth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler {
|
||||
// Expect "Bearer <token>"
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_AUTH_BEARER", "authorization header must be Bearer <token>", nil)
|
||||
// Malformed header - continue as public request
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtManager.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_TOKEN", "invalid or expired token", nil)
|
||||
// Invalid or expired token - continue as public request.
|
||||
// RequireAuth will reject on protected routes.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// SetupRoutes configures all API routes
|
||||
func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTManager, authFingerprintKey string) *chi.Mux {
|
||||
func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTManager, authFingerprintKey string, shippoAPIKey string) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Apply global middleware
|
||||
@@ -24,7 +24,7 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
checkoutHandler := handlers.NewCheckoutHandler(stripeSecretKey)
|
||||
orderHandler := handlers.NewOrderHandler()
|
||||
addressHandler := handlers.NewAddressHandler()
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey)
|
||||
|
||||
// API v1 routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
|
||||
11
main.go
11
main.go
@@ -27,7 +27,10 @@ const (
|
||||
port = "23456"
|
||||
)
|
||||
|
||||
var authService *auth.SSHAuthService
|
||||
var (
|
||||
authService *auth.SSHAuthService
|
||||
apiURL string
|
||||
)
|
||||
|
||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
// Extract SSH public key from session
|
||||
@@ -54,7 +57,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
}
|
||||
|
||||
// Create TUI model with user context (no registration screen needed!)
|
||||
m := tui.NewModelWithAuth(user, false, s.PublicKey(), token)
|
||||
m := tui.NewModelWithAuth(user, false, s.PublicKey(), token, apiURL)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
}
|
||||
|
||||
@@ -84,6 +87,10 @@ func main() {
|
||||
// Initialize SSH auth service
|
||||
authService = auth.NewSSHAuthService(db, jwtManager)
|
||||
|
||||
// Set API URL from config for TUI connections
|
||||
apiURL = cfg.APIURL
|
||||
log.Printf("TUI will connect to API at: %s", apiURL)
|
||||
|
||||
// Create SSH server
|
||||
s, err := wish.NewServer(
|
||||
wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
|
||||
|
||||
@@ -169,20 +169,13 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
|
||||
|
||||
// CheckoutRequest represents the checkout payload sent to the API
|
||||
type CheckoutRequest struct {
|
||||
StripeToken string `json:"stripe_token"`
|
||||
Last4 string `json:"last4"`
|
||||
Brand string `json:"brand"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Items []CheckoutCartItem `json:"items"`
|
||||
ShippingName string `json:"shipping_name"`
|
||||
ShippingStreet string `json:"shipping_street"`
|
||||
ShippingStreet2 string `json:"shipping_street2,omitempty"`
|
||||
ShippingCountry string `json:"shipping_country"`
|
||||
ShippingCity string `json:"shipping_city"`
|
||||
ShippingState string `json:"shipping_state"`
|
||||
ShippingZip string `json:"shipping_zip"`
|
||||
ShippingPhone string `json:"shipping_phone,omitempty"`
|
||||
StripeToken string `json:"stripe_token"`
|
||||
Last4 string `json:"last4"`
|
||||
Brand string `json:"brand"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Items []CheckoutCartItem `json:"items"`
|
||||
AddressID uint `json:"address_id"`
|
||||
}
|
||||
|
||||
type CheckoutCartItem struct {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
APIPort string
|
||||
APIURL string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
@@ -22,16 +23,21 @@ type Config struct {
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
func Load() (*Config, error) {
|
||||
// Load .env file in non-production environments
|
||||
// Load .env file in non-production environments.
|
||||
// Try current directory first, then parent directory (for when
|
||||
// running from subdirectories like api/).
|
||||
if os.Getenv("ENVIRONMENT") != "production" {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
// .env is optional in production
|
||||
fmt.Println("No .env file found, using environment variables")
|
||||
if err := godotenv.Load("../.env"); err != nil {
|
||||
fmt.Println("No .env file found, using environment variables")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apiPort := getEnvOrDefault("API_PORT", "8080")
|
||||
cfg := &Config{
|
||||
APIPort: getEnvOrDefault("API_PORT", "8080"),
|
||||
APIPort: apiPort,
|
||||
APIURL: getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort)),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
|
||||
@@ -53,8 +53,11 @@ type addressRequest struct {
|
||||
}
|
||||
|
||||
// validationResult is Shippo's validation response nested under validation_results.
|
||||
// IsValid is a pointer to distinguish between absent (nil) and explicitly false.
|
||||
// Shippo returns empty validation_results for international addresses, which
|
||||
// means "no data to validate" rather than "invalid".
|
||||
type validationResult struct {
|
||||
IsValid bool `json:"is_valid"`
|
||||
IsValid *bool `json:"is_valid"`
|
||||
}
|
||||
|
||||
// addressResponse is the response from Shippo's address endpoint.
|
||||
@@ -118,8 +121,12 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Accept if test mode or validation says valid
|
||||
if !result.Test && (result.ValidationResults == nil || !result.ValidationResults.IsValid) {
|
||||
// TODO: Re-add !result.Test bypass before production if needed.
|
||||
// Removed test mode bypass so we can verify Shippo validation in development.
|
||||
//
|
||||
// Shippo returns empty validation_results (no is_valid field) for international
|
||||
// addresses. We only reject when is_valid is explicitly false.
|
||||
if result.ValidationResults != nil && result.ValidationResults.IsValid != nil && !*result.ValidationResults.IsValid {
|
||||
return nil, fmt.Errorf("address is invalid")
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ func TestModel_BuildMenuView(t *testing.T) {
|
||||
ShippingView int
|
||||
AddressCursor int
|
||||
StripeKey string
|
||||
ShippoKey string
|
||||
Orders []models.Order
|
||||
OrdersLoaded bool
|
||||
}
|
||||
@@ -92,7 +91,6 @@ func TestModel_BuildMenuView(t *testing.T) {
|
||||
ShippingView: tt.fields.ShippingView,
|
||||
AddressCursor: tt.fields.AddressCursor,
|
||||
StripeKey: tt.fields.StripeKey,
|
||||
ShippoKey: tt.fields.ShippoKey,
|
||||
Orders: tt.fields.Orders,
|
||||
OrdersLoaded: tt.fields.OrdersLoaded,
|
||||
}
|
||||
|
||||
166
pkg/tui/model.go
166
pkg/tui/model.go
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/shippo"
|
||||
)
|
||||
|
||||
// termSize represents the terminal size category for responsive layout.
|
||||
@@ -87,7 +87,6 @@ type Model struct {
|
||||
ShippingView int
|
||||
AddressCursor int
|
||||
StripeKey string // Stripe publishable key for client-side tokenization
|
||||
ShippoKey string // Shippo API key for address validation
|
||||
|
||||
// Order history
|
||||
Orders []models.Order
|
||||
@@ -131,10 +130,17 @@ type ProductsMsg struct {
|
||||
// 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}
|
||||
}
|
||||
|
||||
@@ -204,17 +210,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.ViewingCart && m.CheckoutStep == 1 {
|
||||
switch msg := msg.(type) {
|
||||
case ShippingFormCompleteMsg:
|
||||
return m, m.validateAddress(msg)
|
||||
|
||||
case ShippoValidatedMsg:
|
||||
m.ShippingInfo = &msg.Address
|
||||
m.ShippingForm = nil
|
||||
m.CheckoutStep = 2
|
||||
m.PaymentForm = m.InitPaymentForm()
|
||||
if msg.SaveAddress {
|
||||
return m, tea.Batch(m.PaymentForm.form.Init(), m.saveAddressCmd(msg.Address))
|
||||
addr := models.Address{
|
||||
Name: msg.Name,
|
||||
Street: msg.Street1,
|
||||
Street2: msg.Street2,
|
||||
City: msg.City,
|
||||
State: msg.State,
|
||||
Country: msg.Country,
|
||||
Zip: msg.Zip,
|
||||
Phone: msg.Phone,
|
||||
}
|
||||
return m, m.PaymentForm.form.Init()
|
||||
return m, m.saveAddressCmd(addr)
|
||||
|
||||
case AddressesMsg:
|
||||
if msg.Err != nil || len(msg.Addresses) == 0 {
|
||||
@@ -229,15 +235,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.ShippingForm = nil
|
||||
return m, nil
|
||||
|
||||
case ShippoValidationErrMsg:
|
||||
if m.ShippingForm != nil {
|
||||
m.ShippingForm.submitting = false
|
||||
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
|
||||
case AddressSavedMsg:
|
||||
if msg.Err != nil {
|
||||
if m.ShippingForm != nil {
|
||||
m.ShippingForm.submitting = false
|
||||
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
|
||||
}
|
||||
m.ErrorMsg = fmt.Sprintf("Invalid address: %v", msg.Err)
|
||||
return m, m.ShippingForm.form.Init()
|
||||
}
|
||||
m.ErrorMsg = fmt.Sprintf("Invalid address: %v", msg.Err)
|
||||
return m, nil
|
||||
m.ShippingInfo = &msg.Address
|
||||
m.ShippingForm = nil
|
||||
m.CheckoutStep = 2
|
||||
m.PaymentForm = m.InitPaymentForm()
|
||||
return m, m.PaymentForm.form.Init()
|
||||
|
||||
case ShippingFormErrorMsg:
|
||||
m.ErrorMsg = msg.Message
|
||||
return m, nil
|
||||
@@ -388,22 +400,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case ShippingFormCompleteMsg:
|
||||
return m, m.validateAddress(msg)
|
||||
addr := models.Address{
|
||||
Name: msg.Name,
|
||||
Street: msg.Street1,
|
||||
Street2: msg.Street2,
|
||||
City: msg.City,
|
||||
State: msg.State,
|
||||
Country: msg.Country,
|
||||
Zip: msg.Zip,
|
||||
Phone: msg.Phone,
|
||||
}
|
||||
return m, m.saveAddressCmd(addr)
|
||||
|
||||
case ShippoValidatedMsg:
|
||||
case AddressSavedMsg:
|
||||
if msg.Err != nil {
|
||||
if m.ShippingForm != nil {
|
||||
m.ShippingForm.submitting = false
|
||||
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
|
||||
}
|
||||
m.ErrorMsg = fmt.Sprintf("Invalid address: %v", msg.Err)
|
||||
if m.ShippingForm != nil {
|
||||
return m, m.ShippingForm.form.Init()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.ShippingInfo = &msg.Address
|
||||
m.ShippingForm = nil
|
||||
m.CheckoutStep = 2
|
||||
m.PaymentForm = m.InitPaymentForm()
|
||||
if msg.SaveAddress {
|
||||
return m, tea.Batch(m.PaymentForm.form.Init(), m.saveAddressCmd(msg.Address))
|
||||
}
|
||||
return m, m.PaymentForm.form.Init()
|
||||
|
||||
case ShippoValidationErrMsg:
|
||||
m.ErrorMsg = fmt.Sprintf("invalid address: %v", msg.Err)
|
||||
return m, nil
|
||||
|
||||
case ShippingFormErrorMsg:
|
||||
m.ErrorMsg = msg.Message
|
||||
return m, nil
|
||||
@@ -661,68 +687,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ShippoValidatedMsg is sent when Shippo address validation succeeds
|
||||
type ShippoValidatedMsg struct {
|
||||
Address models.Address
|
||||
SaveAddress bool
|
||||
}
|
||||
|
||||
// ShippoValidationErrMsg is sent when Shippo address validation fails
|
||||
type ShippoValidationErrMsg struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// validateAddress sends the shipping address to Shippo for validation
|
||||
func (m Model) validateAddress(info ShippingFormCompleteMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.ShippoKey == "" {
|
||||
// No Shippo key configured, skip validation
|
||||
return ShippoValidatedMsg{
|
||||
Address: models.Address{
|
||||
Name: info.Name,
|
||||
Street: info.Street1,
|
||||
Street2: info.Street2,
|
||||
City: info.City,
|
||||
State: info.State,
|
||||
Country: info.Country,
|
||||
Zip: info.Zip,
|
||||
Phone: info.Phone,
|
||||
},
|
||||
SaveAddress: info.SaveAddress,
|
||||
}
|
||||
}
|
||||
|
||||
client := shippo.NewClient(m.ShippoKey)
|
||||
validated, err := client.ValidateAddress(shippo.Address{
|
||||
Name: info.Name,
|
||||
Street1: info.Street1,
|
||||
Street2: info.Street2,
|
||||
City: info.City,
|
||||
State: info.State,
|
||||
Country: info.Country,
|
||||
Zip: info.Zip,
|
||||
Phone: info.Phone,
|
||||
})
|
||||
if err != nil {
|
||||
return ShippoValidationErrMsg{Err: err}
|
||||
}
|
||||
|
||||
return ShippoValidatedMsg{
|
||||
Address: models.Address{
|
||||
Name: validated.Name,
|
||||
Street: validated.Street1,
|
||||
Street2: validated.Street2,
|
||||
City: validated.City,
|
||||
State: validated.State,
|
||||
Country: validated.Country,
|
||||
Zip: validated.Zip,
|
||||
Phone: validated.Phone,
|
||||
},
|
||||
SaveAddress: info.SaveAddress,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type StripeTokenMsg struct {
|
||||
TokenID string
|
||||
Last4 string
|
||||
@@ -880,17 +844,10 @@ func (m Model) submitCheckout(tok StripeTokenMsg) tea.Cmd {
|
||||
}
|
||||
|
||||
req := api.CheckoutRequest{
|
||||
StripeToken: tok.TokenID,
|
||||
Last4: tok.Last4,
|
||||
Items: cartItems,
|
||||
ShippingName: m.ShippingInfo.Name,
|
||||
ShippingStreet: m.ShippingInfo.Street,
|
||||
ShippingStreet2: m.ShippingInfo.Street2,
|
||||
ShippingCity: m.ShippingInfo.City,
|
||||
ShippingState: m.ShippingInfo.State,
|
||||
ShippingZip: m.ShippingInfo.Zip,
|
||||
ShippingCountry: m.ShippingInfo.Country,
|
||||
ShippingPhone: m.ShippingInfo.Phone,
|
||||
StripeToken: tok.TokenID,
|
||||
Last4: tok.Last4,
|
||||
Items: cartItems,
|
||||
AddressID: m.ShippingInfo.ID,
|
||||
}
|
||||
|
||||
order, err := m.APIClient.Checkout(req)
|
||||
@@ -999,14 +956,13 @@ func NewModel(username string) Model {
|
||||
Loading: true,
|
||||
APIClient: api.NewClient("http://localhost:8080", ""),
|
||||
StripeKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
ShippoKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
}
|
||||
m.updateLayout(120, 30)
|
||||
return m
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context
|
||||
func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey, token string) Model {
|
||||
func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey, token string, apiURL string) Model {
|
||||
username := "guest"
|
||||
if user != nil {
|
||||
username = user.Name
|
||||
@@ -1017,7 +973,11 @@ func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey,
|
||||
m.IsNewUser = isNewUser
|
||||
m.SSHPublicKey = pubKey
|
||||
m.AccessToken = token
|
||||
m.APIClient = api.NewClient("http://localhost:8080", token)
|
||||
if apiURL != "" {
|
||||
m.APIClient = api.NewClient(apiURL, token)
|
||||
} else {
|
||||
m.APIClient = api.NewClient("http://localhost:8080", token)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ func TestModel_updateLayout(t *testing.T) {
|
||||
ShippingView int
|
||||
AddressCursor int
|
||||
StripeKey string
|
||||
ShippoKey string
|
||||
Orders []models.Order
|
||||
OrdersLoaded bool
|
||||
}
|
||||
@@ -96,7 +95,6 @@ func TestModel_updateLayout(t *testing.T) {
|
||||
ShippingView: tt.fields.ShippingView,
|
||||
AddressCursor: tt.fields.AddressCursor,
|
||||
StripeKey: tt.fields.StripeKey,
|
||||
ShippoKey: tt.fields.ShippoKey,
|
||||
Orders: tt.fields.Orders,
|
||||
OrdersLoaded: tt.fields.OrdersLoaded,
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ type ShippingFormCompleteMsg struct {
|
||||
Country string
|
||||
Zip string
|
||||
Phone string
|
||||
SaveAddress bool
|
||||
}
|
||||
|
||||
// ShippingFormErrorMsg is sent when form validation fails at submission time
|
||||
@@ -227,7 +226,6 @@ func (m Model) UpdateShippingForm(msg tea.Msg, state *ShippingFormState) tea.Cmd
|
||||
Country: state.Country,
|
||||
Zip: state.Zip,
|
||||
Phone: phone,
|
||||
SaveAddress: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Submodule terminal-shop-source updated: caa24e7ffa...697730e73c
Reference in New Issue
Block a user