diff --git a/api/handlers/checkout.go b/api/handlers/checkout.go new file mode 100644 index 0000000..b8937e1 --- /dev/null +++ b/api/handlers/checkout.go @@ -0,0 +1,179 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/charge" + "github.com/stripe/stripe-go/v78/customer" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" +) + +// CheckoutHandler handles checkout and order creation. +type CheckoutHandler struct { + stripeKey string +} + +// NewCheckoutHandler creates a new checkout handler with the Stripe secret key. +func NewCheckoutHandler(stripeSecretKey string) *CheckoutHandler { + return &CheckoutHandler{stripeKey: stripeSecretKey} +} + +type CheckoutCartItem struct { + CoffeeID uint `json:"coffee_id"` + Quantity int `json:"quantity"` +} + +type CheckoutRequest struct { + Fingerprint string `json:"fingerprint"` + 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"` + ShippingCity string `json:"shipping_city"` + ShippingState string `json:"shipping_state"` + ShippingZip string `json:"shipping_zip"` +} + +func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + var req CheckoutRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + if req.Fingerprint == "" { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "fingerprint is required", nil) + return + } + + if req.StripeToken == "" { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "stripe token is required", nil) + return + } + + if len(req.Items) <= 0 { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil) + return + } + + var user models.User + if err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil) + return + } + + stripe.Key = h.stripeKey + + if user.StripeCustomerID == "" { + params := &stripe.CustomerParams{ + Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)), + } + + if user.Email != "" { + params.Email = stripe.String(user.Email) + } + if user.Name != "" { + params.Name = stripe.String(user.Name) + } + cust, err := customer.New(params) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) + return + } + user.StripeCustomerID = cust.ID + db.Save(&user) + } + + var subtotal int + var orderItems []models.OrderItem + + for _, item := range req.Items { + 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) + lineTotal := priceInCents * item.Quantity + subtotal += lineTotal + + orderItems = append(orderItems, models.OrderItem{ + CoffeeID: coffee.ID, + Name: coffee.Name, + Price: priceInCents, + Quantity: item.Quantity, + }) + } + + total := subtotal + + chargeParams := &stripe.ChargeParams{ + Amount: stripe.Int64(int64(total)), + Currency: stripe.String(string(stripe.CurrencyUSD)), + Customer: stripe.String(user.StripeCustomerID), + Description: stripe.String(fmt.Sprintf("terminal.shop order for user %d", user.ID)), + } + chargeParams.SetSource(req.StripeToken) + + // Stripe minimum charge is $0.50 (50 cents) + if total < 50 { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil) + return + } + + ch, err := charge.New(chargeParams) + if err != nil { + utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", fmt.Sprintf("payment failed: %v", err), nil) + return + } + + card := models.Card{ + UserID: user.ID, + StripePaymentID: req.StripeToken, + Last4: req.Last4, + Brand: req.Brand, + ExpMonth: req.ExpMonth, + ExpYear: req.ExpYear, + } + + db.Create(&card) + + order := models.Order{ + UserID: user.ID, + CardID: card.ID, + StripePaymentID: ch.ID, + Status: models.OrderStatusPaid, + Subtotal: subtotal, + Total: total, + ShippingName: req.ShippingName, + ShippingStreet: req.ShippingStreet, + ShippingCity: req.ShippingCity, + ShippingState: req.ShippingState, + ShippingZip: req.ShippingZip, + Items: orderItems, + } + + if err := db.Create(&order).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil) + return + } + + db.Preload("Items").First(&order, order.ID) + + utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ + "order": order, + }) +} diff --git a/api/handlers/orders.go b/api/handlers/orders.go new file mode 100644 index 0000000..001989d --- /dev/null +++ b/api/handlers/orders.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" +) + + +type OrderHandler struct {} + + +func NewOrderHandler() *OrderHandler { + return &OrderHandler{} +} + + +func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + fingerprint := r.URL.Query().Get("fingerprint") + if fingerprint == "" { + utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint query parameter is required", nil) + return + } + var user models.User + if err := db.Where("ssh_key_fingerprint = ?", fingerprint).First(&user).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil) + return + } + + var orders []models.Order + db.Where("user_id = ?", user.ID).Preload("Items").Order("created_at desc").Find(&orders) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "orders": orders, + }) +} diff --git a/api/main.go b/api/main.go index e2c298a..038e879 100644 --- a/api/main.go +++ b/api/main.go @@ -42,7 +42,7 @@ func main() { } // Setup routes - router := routes.SetupRoutes(version) + router := routes.SetupRoutes(version, cfg.StripeSecretKey) // Create HTTP server server := &http.Server{ diff --git a/api/routes/routes.go b/api/routes/routes.go index e5dfa35..b9e06ab 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -8,7 +8,7 @@ import ( ) // SetupRoutes configures all API routes -func SetupRoutes(version string) *chi.Mux { +func SetupRoutes(version string, stripeSecretKey string) *chi.Mux { r := chi.NewRouter() // Apply global middleware @@ -20,6 +20,8 @@ func SetupRoutes(version string) *chi.Mux { healthHandler := handlers.NewHealthHandler(version) productHandler := handlers.NewProductHandler() authHandler := handlers.NewAuthHandler() + checkoutHandler := handlers.NewCheckoutHandler(stripeSecretKey) + orderHandler := handlers.NewOrderHandler() // API v1 routes r.Route("/api/v1", func(r chi.Router) { @@ -33,12 +35,17 @@ func SetupRoutes(version string) *chi.Mux { // Product endpoints (full CRUD) r.Route("/products", func(r chi.Router) { - r.Get("/", productHandler.GetProducts) // GET /api/v1/products - r.Post("/", productHandler.CreateProduct) // POST /api/v1/products - r.Get("/{id}", productHandler.GetProduct) // GET /api/v1/products/:id - r.Put("/{id}", productHandler.UpdateProduct) // PUT /api/v1/products/:id + r.Get("/", productHandler.GetProducts) // GET /api/v1/products + r.Post("/", productHandler.CreateProduct) // POST /api/v1/products + r.Get("/{id}", productHandler.GetProduct) // GET /api/v1/products/:id + r.Put("/{id}", productHandler.UpdateProduct) // PUT /api/v1/products/:id r.Delete("/{id}", productHandler.DeleteProduct) // DELETE /api/v1/products/:id }) + // Checkout endpoint + r.Post("/checkout", checkoutHandler.Checkout) + + // Order endpoints + r.Get("/orders", orderHandler.GetOrders) }) return r diff --git a/pkg/api/client.go b/pkg/api/client.go index 4b049a2..e609d8d 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -142,3 +142,92 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string) return ®isterResp.Data.User, nil } + +// CheckoutRequest represents the checkout payload sent to the API +type CheckoutRequest struct { + Fingerprint string `json:"fingerprint"` + 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"` + ShippingCity string `json:"shipping_city"` + ShippingState string `json:"shipping_state"` + ShippingZip string `json:"shipping_zip"` +} + +type CheckoutCartItem struct { + CoffeeID uint `json:"coffee_id"` + Quantity int `json:"quantity"` +} + +type CheckoutResponse struct { + Success bool `json:"success"` + Data struct { + Order models.Order `json:"order"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +type OrderResponse struct { + Success bool `json:"success"` + Data struct { + Orders []models.Order `json:"orders"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +func (c *Client) Checkout(req CheckoutRequest) (*models.Order, error) { + url := fmt.Sprintf("%s/api/v1/checkout", c.BaseURL) + + jsonData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal checkout request: %w", err) + } + + resp, err := c.HTTPClient.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("checkout request failed: %w", err) + } + + defer resp.Body.Close() + + var checkoutResp CheckoutResponse + if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil { + return nil, fmt.Errorf("failed to decode checkout response: %w", err) + } + + if !checkoutResp.Success { + if checkoutResp.Error != nil { + return nil, fmt.Errorf("%s: %s", checkoutResp.Error.Code, checkoutResp.Error.Message) + } + return nil, fmt.Errorf("checkout failed") + } + return &checkoutResp.Data.Order, nil +} + +func (c *Client) GetOrders(fingerprint string) ([]models.Order, error) { + url := fmt.Sprintf("%s/api/v1/orders?fingerprint=%s", c.BaseURL, fingerprint) + + resp, err := c.HTTPClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch orders: %w", err) + } + defer resp.Body.Close() + + var ordersResp OrderResponse + if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil { + return nil, fmt.Errorf("failed to decode orders response: %w", err) + } + + if !ordersResp.Success { + if ordersResp.Error != nil { + return nil, fmt.Errorf("%s: %s", ordersResp.Error.Code, ordersResp.Error.Message) + } + return nil, fmt.Errorf("failed to fetch orders") + } + return ordersResp.Data.Orders, nil +} diff --git a/pkg/database/migrate.go b/pkg/database/migrate.go index 846af7c..bd3faa0 100644 --- a/pkg/database/migrate.go +++ b/pkg/database/migrate.go @@ -16,10 +16,10 @@ func Migrate(db *gorm.DB) error { // It will NOT delete unused columns or change column types if err := db.AutoMigrate( &models.Coffee{}, - &models.User{}, // SSH key-based authentication - // Add other models here as they're created - // &models.Cart{}, - // &models.Order{}, + &models.User{}, // SSH key-based authentication + &models.Card{}, // Saved payment methods + &models.Order{}, // Completed purchases + &models.OrderItem{}, // Line items within orders ); err != nil { return fmt.Errorf("migration failed: %w", err) } diff --git a/pkg/models/card.go b/pkg/models/card.go new file mode 100644 index 0000000..1b68e09 --- /dev/null +++ b/pkg/models/card.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Card represents a saved payment method linked to a Stripe token. +// The actual card details live on Stripe's servers — we only store +// a reference and display info (last 4 digits, brand). +type Card struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID + Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display + Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc. + ExpMonth int `gorm:"not null" json:"exp_month"` + ExpYear int `gorm:"not null" json:"exp_year"` + IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Associations + User User `gorm:"foreignKey:UserID" json:"-"` +} + +// TableName specifies the table name for the Card model. +func (Card) TableName() string { + return "cards" +} diff --git a/pkg/models/order.go b/pkg/models/order.go new file mode 100644 index 0000000..50ca380 --- /dev/null +++ b/pkg/models/order.go @@ -0,0 +1,75 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// OrderStatus represents the lifecycle of an order. +type OrderStatus string + +const ( + OrderStatusPending OrderStatus = "pending" + OrderStatusPaid OrderStatus = "paid" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusDelivered OrderStatus = "delivered" + OrderStatusCancelled OrderStatus = "cancelled" + OrderStatusRefunded OrderStatus = "refunded" +) + +// Order represents a completed purchase. +type Order struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + CardID uint `gorm:"not null" json:"card_id"` + StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID + Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"` + Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents + ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents + Total int `gorm:"not null" json:"total"` // Amount in cents + ShippingName string `gorm:"size:255" json:"shipping_name"` + ShippingStreet string `gorm:"size:255" json:"shipping_street"` + ShippingCity string `gorm:"size:255" json:"shipping_city"` + ShippingState string `gorm:"size:50" json:"shipping_state"` + ShippingZip string `gorm:"size:20" json:"shipping_zip"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Associations + User User `gorm:"foreignKey:UserID" json:"-"` + Card Card `gorm:"foreignKey:CardID" json:"-"` + Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` +} + +// TableName specifies the table name for the Order model. +func (Order) TableName() string { + return "orders" +} + +// OrderItem represents a single line item within an order. +type OrderItem struct { + ID uint `gorm:"primaryKey" json:"id"` + OrderID uint `gorm:"not null;index" json:"order_id"` + CoffeeID uint `gorm:"not null" json:"coffee_id"` + Name string `gorm:"size:255;not null" json:"name"` // Snapshot of coffee name at time of order + Price int `gorm:"not null" json:"price"` // Price in cents at time of order + Quantity int `gorm:"not null" json:"quantity"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Associations + Order Order `gorm:"foreignKey:OrderID" json:"-"` + Coffee Coffee `gorm:"foreignKey:CoffeeID" json:"-"` +} + +// TableName specifies the table name for the OrderItem model. +func (OrderItem) TableName() string { + return "order_items" +} + +// TotalInDollars returns the order total formatted as a float for display. +func (o *Order) TotalInDollars() float64 { + return float64(o.Total) / 100 +} diff --git a/pkg/models/user.go b/pkg/models/user.go index 53d3778..a7a5d29 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -11,10 +11,11 @@ import ( type User struct { ID uint `gorm:"primaryKey" json:"id"` SSHKeyFingerprint string `gorm:"uniqueIndex;size:100;not null" json:"ssh_key_fingerprint"` // SHA256 fingerprint - PRIMARY identifier - SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key - Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels) - Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts) - Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key + SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key + Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels) + Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts) + Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key + StripeCustomerID string `gorm:"size:255" json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 5701a23..99e85ff 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -1,15 +1,15 @@ package tui import ( + "encoding/json" "fmt" + "net/http" "os" + "strings" tea "github.com/charmbracelet/bubbletea" gossh "golang.org/x/crypto/ssh" - "github.com/stripe/stripe-go/v78" - "github.com/stripe/stripe-go/v78/token" - "terminalShop/pkg/api" "terminalShop/pkg/models" "terminalShop/pkg/shippo" @@ -48,6 +48,10 @@ type Model struct { AddressCursor int StripeKey string // Stripe publishable key for client-side tokenization ShippoKey string // Shippo API key for address validation + + // Order history + Orders []models.Order + OrdersLoaded bool } // ProductsMsg is sent when products are fetched from API @@ -174,9 +178,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // kick off Stripe tokenization (runs async) return m, m.tokenizeCard(msg) case StripeTokenMsg: - // Tokenization completed, move to confirmation - m.CheckoutStep = 3 m.PaymentForm = nil + return m, m.submitCheckout(msg) + case CheckoutResultMsg: + if msg.Err != nil { + m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err) + m.CheckoutStep = 2 + m.PaymentForm = m.InitPaymentForm() + return m, m.PaymentForm.form.Init() + } + m.CheckoutStep = 3 return m, nil case StripeTokenErrMsg: // Tokenization failed, show error and let user retry @@ -268,6 +279,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case OrdersMsg: + if msg.Err == nil { + m.Orders = msg.Orders + m.OrdersLoaded = true + } + return m, nil + case tea.WindowSizeMsg: m.WindowWidth = msg.Width m.WindowHeight = msg.Height @@ -302,6 +320,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ShippingForm = nil m.PaymentForm = nil m.ScrollOffset = 0 + if !m.OrdersLoaded { + return m, m.fetchOrdersCmd() + } case "p", "enter": // Proceed to checkout from cart @@ -515,33 +536,119 @@ type StripeTokenErrMsg struct { Err error } +type CheckoutResultMsg struct { + OrderID uint + Total int + Err error +} + +// OrdersMsg is sent when order history is fetched from the API +type OrdersMsg struct { + Orders []models.Order + Err error +} + +func (m Model) fetchOrdersCmd() tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil || m.User == nil { + return OrdersMsg{Err: fmt.Errorf("not authenticated")} + } + orders, err := m.APIClient.GetOrders(m.User.SSHKeyFingerprint) + return OrdersMsg{Orders: orders, Err: err} + } +} + func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd { return func() tea.Msg { - stripe.Key = m.StripeKey + // Use Stripe's HTTP API directly with the publishable key. + // The Go SDK's token.New() is blocked for raw card numbers + // unless your account has direct PCI compliance enabled. + // This HTTP POST mimics what a browser does with Stripe.js. + pubKey := m.StripeKey - params := &stripe.TokenParams{ - Card: &stripe.CardParams{ - Number: stripe.String(card.CardNumber), - ExpMonth: stripe.String(card.ExpiryMonth), - ExpYear: stripe.String(card.ExpiryYear), - CVC: stripe.String(card.CVC), - }, - } + data := fmt.Sprintf( + "card[number]=%s&card[exp_month]=%s&card[exp_year]=%s&card[cvc]=%s", + card.CardNumber, card.ExpiryMonth, card.ExpiryYear, card.CVC, + ) - tok, err := token.New(params) + req, err := http.NewRequest("POST", "https://api.stripe.com/v1/tokens", strings.NewReader(data)) if err != nil { return StripeTokenErrMsg{Err: err} } - last4 := "" - if tok.Card != nil { - last4 = tok.Card.Last4 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Bearer "+pubKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return StripeTokenErrMsg{Err: err} + } + defer resp.Body.Close() + + var result struct { + ID string `json:"id"` + Card struct { + Last4 string `json:"last4"` + Brand string `json:"brand"` + } `json:"card"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return StripeTokenErrMsg{Err: err} + } + + if result.Error != nil { + return StripeTokenErrMsg{Err: fmt.Errorf("%s", result.Error.Message)} } return StripeTokenMsg{ - TokenID: tok.ID, - Last4: last4, + TokenID: result.ID, + Last4: result.Card.Last4, + } + } +} + +func (m Model) submitCheckout(tok StripeTokenMsg) tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil { + return CheckoutResultMsg{Err: fmt.Errorf("API client not available")} } + items := m.GetCartItemsSlice() + cartItems := make([]api.CheckoutCartItem, 0, len(items)) + for _, item := range items { + cartItems = append(cartItems, api.CheckoutCartItem{ + CoffeeID: item.Coffee.ID, + Quantity: item.Quantity, + }) + } + + req := api.CheckoutRequest{ + StripeToken: tok.TokenID, + Last4: tok.Last4, + Items: cartItems, + ShippingName: m.ShippingInfo.Name, + ShippingStreet: m.ShippingInfo.Street1, + ShippingCity: m.ShippingInfo.City, + ShippingState: m.ShippingInfo.State, + ShippingZip: m.ShippingInfo.Zip, + } + + if m.User != nil { + req.Fingerprint = m.User.SSHKeyFingerprint + } + + order, err := m.APIClient.Checkout(req) + if err != nil { + return CheckoutResultMsg{Err: err} + } + + return CheckoutResultMsg{ + OrderID: order.ID, + Total: order.Total, + } } } diff --git a/test_stripe.go b/test_stripe.go new file mode 100644 index 0000000..75875fa --- /dev/null +++ b/test_stripe.go @@ -0,0 +1,56 @@ +//go:build ignore + +package main + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/paymentintent" + "github.com/stripe/stripe-go/v78/paymentmethod" +) + +func main() { + godotenv.Load() + + secKey := os.Getenv("STRIPE_SECRET_KEY") + if secKey == "" { + fmt.Println("FAILED: missing STRIPE_SECRET_KEY in .env") + return + } + + stripe.Key = secKey + + // Step 1: Create a PaymentMethod using Stripe's test token (tok_visa = Visa 4242) + pm, err := paymentmethod.New(&stripe.PaymentMethodParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCardParams{ + Token: stripe.String("tok_visa"), + }, + }) + if err != nil { + fmt.Printf("FAILED payment method: %v\n", err) + return + } + fmt.Printf("1. PaymentMethod created: %s (brand: %s, last4: %s)\n", pm.ID, pm.Card.Brand, pm.Card.Last4) + + // Step 2: Create a PaymentIntent and confirm it immediately + pi, err := paymentintent.New(&stripe.PaymentIntentParams{ + Amount: stripe.Int64(999999), // $10.00 in cents + Currency: stripe.String("usd"), + PaymentMethod: stripe.String(pm.ID), + Confirm: stripe.Bool(true), + AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ + Enabled: stripe.Bool(true), + AllowRedirects: stripe.String("never"), + }, + }) + if err != nil { + fmt.Printf("FAILED payment intent: %v\n", err) + return + } + fmt.Printf("2. PaymentIntent created: %s (status: %s, amount: $%.2f)\n", pi.ID, pi.Status, float64(pi.Amount)/100) + fmt.Println("\nSUCCESS! Stripe is fully working.") +}