mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
something
This commit is contained in:
179
api/handlers/checkout.go
Normal file
179
api/handlers/checkout.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
40
api/handlers/orders.go
Normal file
40
api/handlers/orders.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Setup routes
|
||||
router := routes.SetupRoutes(version)
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
32
pkg/models/card.go
Normal file
32
pkg/models/card.go
Normal file
@@ -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"
|
||||
}
|
||||
75
pkg/models/order.go
Normal file
75
pkg/models/order.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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:"-"`
|
||||
|
||||
147
pkg/tui/model.go
147
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
56
test_stripe.go
Normal file
56
test_stripe.go
Normal file
@@ -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.")
|
||||
}
|
||||
Reference in New Issue
Block a user