something

This commit is contained in:
2026-02-23 19:51:13 +01:00
parent c5e7a3c290
commit 59cd90b6a9
11 changed files with 620 additions and 34 deletions

View File

@@ -142,3 +142,92 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
return &registerResp.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
}

View File

@@ -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
View 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
View 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
}

View File

@@ -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:"-"`

View File

@@ -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,
}
}
}