mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
Compare commits
186 Commits
docker
...
fix/slack-
| Author | SHA1 | Date | |
|---|---|---|---|
| f61d9118a0 | |||
| d7f2189394 | |||
| ebd3e20285 | |||
| 9d5096b270 | |||
| 85ed872c28 | |||
| 2c12ba586e | |||
| 0c0b8e8059 | |||
| 7148ff1d4d | |||
| 22eeeeeac1 | |||
| aa446cdfa5 | |||
| 8df1058932 | |||
| 64b6f0d91a | |||
| 5dd196834f | |||
| e85dc7fd4d | |||
| ff562c7427 | |||
| aa34043e9b | |||
| 5dd22fb8be | |||
| 52d533703b | |||
| c9f8b1e674 | |||
| d592249efb | |||
| cfc97eb1ff | |||
| c112797875 | |||
| 75225548ae | |||
| 53e934be3b | |||
| fc02f331d2 | |||
| bb960c31f8 | |||
| 8b753e4f4b | |||
| 90645958e5 | |||
| 135f1361e4 | |||
| 64deba7e03 | |||
| 732353d9a4 | |||
| 3420c8dce0 | |||
| f3b94c3271 | |||
| 67e19eca98 | |||
| 728ac24c96 | |||
| 1096c85b10 | |||
| 2f65cf0d90 | |||
| c3e6eb2b84 | |||
| 03009ea6df | |||
| c8b7545ca6 | |||
| 3a3c7ee80b | |||
| 7c8a2b562f | |||
| 1c5765d25b | |||
| e6301e662f | |||
| 8b2889a524 | |||
| c2b2b6e834 | |||
| bba5a0dfa0 | |||
| a8e45bc82b | |||
| 95b9fa91b1 | |||
| 0fe013c399 | |||
| d9404ab31e | |||
| a21485f91d | |||
| bce94b6d10 | |||
| dee6172875 | |||
| b404303f84 | |||
| 48d65c48fa | |||
| c78021f520 | |||
| c429c1966e | |||
| 50e1ba32f3 | |||
| 85b0c53c0a | |||
| 366d80a9ee | |||
| 60f52f9851 | |||
| a4c446ba3c | |||
| 491f70e935 | |||
| 153e487155 | |||
| 1752ca534f | |||
| b2bd6ed623 | |||
| 96400ede14 | |||
| 30cf46370c | |||
| 31f0698d63 | |||
| 910b7f3141 | |||
| 19e493a3d5 | |||
| 2e6dda190f | |||
| 96e76f7e5b | |||
| 9d3ac9761d | |||
| e49fa01ca9 | |||
| d9e9895913 | |||
| f73e1d18da | |||
| 29f57000a7 | |||
| 1aaf82d7d6 | |||
| 9515603b32 | |||
| bfd183bfbf | |||
| e43040f702 | |||
| 2966474ff5 | |||
| 0e4d267adb | |||
| c1b3ccf302 | |||
| f6eed76456 | |||
| e5ec247a6b | |||
| e29dd6b30d | |||
| ee6e14a424 | |||
| 387758aa13 | |||
| ff9615b75a | |||
| a2afc47f32 | |||
| 37ad51949b | |||
| 99f34788ea | |||
| c22b64d6fa | |||
| a71f72ad71 | |||
| 700ad4926b | |||
| c5f4e492c0 | |||
| f88fe1d8d3 | |||
| c6ff75f2a5 | |||
| add6e573b3 | |||
| 9b45fb5138 | |||
| 1849e324fc | |||
| c90dadf9ad | |||
| 38cdda3d54 | |||
| c037a6e9f9 | |||
| ffb476ec2d | |||
| 177b080117 | |||
| 9bf57b4048 | |||
| c96f922c70 | |||
| 4e28b812bd | |||
| 7b92636b64 | |||
| 17df27ae20 | |||
| 52d58583e9 | |||
| 327bb9c867 | |||
| 9a5fff81bf | |||
| 1c10cf4390 | |||
| ae7eaa8e97 | |||
| 8ca209a4cc | |||
| 9e9a8325a8 | |||
| 179c8011ca | |||
| 9d4549a32d | |||
| 31b8e82e47 | |||
| 67c8a6bafb | |||
| 67f01f4a38 | |||
| f47c10ed8a | |||
| b35103657d | |||
| 42abf11d04 | |||
| 96c9be61ff | |||
| 80166ae8d0 | |||
| 6bcf651afd | |||
| cbb3e59878 | |||
| 00dd63668b | |||
| bd93989036 | |||
| 4083495704 | |||
| 7bebdeebed | |||
| e0ff8f5f76 | |||
| 011bf3a4ff | |||
| d51912917e | |||
| c8a5f4806d | |||
| e2dc2854b7 | |||
| f9885e2d5e | |||
| 34bb7a7c7f | |||
| 79c8238f1b | |||
| 5c38c7e726 | |||
| 4024cf4b40 | |||
| c78f5eb12b | |||
| c8af9cb551 | |||
| d7b172843e | |||
| d66b41eed3 | |||
| 6db7748d9f | |||
| abe11853f6 | |||
| 280585c2d0 | |||
| 8a86965981 | |||
| edc032d6d4 | |||
| 53c1b5e254 | |||
| 1bc03822b1 | |||
| 6006f9617d | |||
| 1c3ebf789c | |||
| bef7f85bda | |||
| fb5881f26f | |||
| 7a774ceee7 | |||
| a624037f65 | |||
| d111dafd62 | |||
| cb048bc3dc | |||
| 22ab7438e1 | |||
| 4403138f74 | |||
| 2d86e1d23f | |||
| b92132474b | |||
| 8157789e66 | |||
| 7a8f7e9c2a | |||
| 44a9de0209 | |||
| 7882667e35 | |||
| a22ba382c2 | |||
| 7cbefeae40 | |||
| e11e708095 | |||
| 6559c4dd8f | |||
| 157a752540 | |||
| de9120d1eb | |||
| 77a044e11d | |||
| 40dccc8139 | |||
| ac04676abb | |||
| 53e443e93d | |||
| 6dba7c6dc6 | |||
| c12b0dcbc2 |
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Format check
|
||||
run: |
|
||||
unformatted=$(gofmt -l $(go list -f '{{.Dir}}' ./...))
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "These files need gofmt:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Lint
|
||||
if: ${{ !env.ACT }}
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: v2.4.0
|
||||
|
||||
- name: Install tparse
|
||||
run: go install github.com/mfridman/tparse@v0.18.0
|
||||
|
||||
- name: Test (race detector)
|
||||
run: |
|
||||
set +e
|
||||
go tool gotestsum \
|
||||
--format pkgname \
|
||||
--format-icons hivis \
|
||||
--hide-summary=skipped,output,errors,failed \
|
||||
--jsonfile test-output.json \
|
||||
--junitfile test-report.xml \
|
||||
--packages="./..." \
|
||||
-- -count=1 -race -covermode=atomic -coverprofile=coverage.out
|
||||
rc=$?
|
||||
echo
|
||||
tparse -all -notests -slow 10 -file=test-output.json || true
|
||||
exit $rc
|
||||
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-report
|
||||
path: |
|
||||
test-output.json
|
||||
test-report.xml
|
||||
coverage.out
|
||||
36
.github/workflows/deploy.yml
vendored
Normal file
36
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
port: ${{ secrets.SERVER_PORT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd ~/terminalShop
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
deployed=$(git rev-parse HEAD)
|
||||
expected="${{ github.sha }}"
|
||||
if [ "$deployed" != "$expected" ]; then
|
||||
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
|
||||
exit 1
|
||||
fi
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
echo "deploy verified: HEAD=$deployed"
|
||||
44
.gitignore
vendored
44
.gitignore
vendored
@@ -3,6 +3,16 @@
|
||||
.env.local
|
||||
.env.production
|
||||
.env.example
|
||||
.env.prod
|
||||
|
||||
docs/sca-psd2-compliance.md
|
||||
accept-a-payment/
|
||||
codemap/
|
||||
|
||||
|
||||
FULFILLMENT.md
|
||||
.tmp-gocache/
|
||||
.junie/
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
@@ -11,14 +21,21 @@
|
||||
*.so
|
||||
*.dylib
|
||||
terminalshop
|
||||
terminalShop
|
||||
server_bin
|
||||
terminalshop_*
|
||||
server.go.old
|
||||
main
|
||||
api/main
|
||||
api/api-server
|
||||
|
||||
# Go test/coverage files
|
||||
*.test
|
||||
*.out
|
||||
coverage.txt
|
||||
coverage.html
|
||||
test-output.json
|
||||
test-report.xml
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
@@ -32,6 +49,8 @@ vendor/
|
||||
.DS_Store
|
||||
.claude/
|
||||
|
||||
.understand-anything/
|
||||
|
||||
# ssh config
|
||||
.ssh/
|
||||
|
||||
@@ -58,7 +77,9 @@ temp/
|
||||
|
||||
# OS-specific
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
CONTRIBUTING.md
|
||||
|
||||
|
||||
|
||||
# random stuff like ai and shit
|
||||
@@ -67,18 +88,35 @@ AGENTS.md
|
||||
MODELS.md
|
||||
REFACTORING.md
|
||||
TODO.md
|
||||
NEXT.md
|
||||
test_stripe.go
|
||||
REFACTOR_PLAN.md
|
||||
|
||||
|
||||
codebase_graph.html
|
||||
|
||||
# Don't commit the cloned reference repo (optional - you might want to keep it)
|
||||
# terminal-shop-source/
|
||||
terminal-shop-source/
|
||||
|
||||
# API documentation (auto-generated)
|
||||
api/docs/
|
||||
docs/
|
||||
|
||||
# Migration state (if using a migration tool that creates state files)
|
||||
migrations/*.state
|
||||
|
||||
gh-dash/
|
||||
|
||||
|
||||
|
||||
loadtest/run.pid
|
||||
loadtest/run.err
|
||||
|
||||
/00
|
||||
|
||||
ROADMAP.md
|
||||
|
||||
*.bak
|
||||
terminalshop.prod.*
|
||||
|
||||
# Backup verification config (real values; example file is committed)
|
||||
scripts/backups/config.env
|
||||
|
||||
16
.golangci.yml
Normal file
16
.golangci.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
exclusions:
|
||||
paths:
|
||||
- terminal-shop-source
|
||||
153
AGENTS.md
153
AGENTS.md
@@ -1,153 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding agents working in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Terminal Coffee Shop is a terminal-based e-commerce app for ordering coffee, built with Go and the Bubbletea TUI framework. It runs as an SSH server (Wish framework) with a REST API backend (Chi router), SQLite database (GORM), and JWT authentication.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
```bash
|
||||
# Build and run SSH server (TUI)
|
||||
go build -o terminalshop && ./terminalshop
|
||||
# Connect: ssh localhost -p 23457
|
||||
|
||||
# Quick run without building
|
||||
go run main.go
|
||||
|
||||
# Run API server (separate process, port 8080)
|
||||
go run main.go # from api/ directory
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run a single test by name
|
||||
go test ./api/handlers/ -run TestGetHealth
|
||||
|
||||
# Run tests in a specific package
|
||||
go test ./pkg/database/
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Run tests with coverage
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `main.go` -- SSH server entry point (Wish framework)
|
||||
- `api/` -- REST API server: `handlers/`, `middleware/`, `routes/`
|
||||
- `pkg/api/` -- API client used by TUI
|
||||
- `pkg/auth/` -- JWT + SSH key authentication
|
||||
- `pkg/config/` -- Environment configuration
|
||||
- `pkg/database/` -- SQLite connection, migrations, seeding
|
||||
- `pkg/models/` -- GORM data models (Coffee, User, CartItem)
|
||||
- `pkg/tui/` -- Bubbletea TUI components
|
||||
- `pkg/utils/` -- Standard API response helpers
|
||||
- `pkg/validate/` -- Generic validators; `pkg/validation/` -- request validation
|
||||
- `terminal-shop-source/` -- Reference implementation (read-only, do not modify)
|
||||
|
||||
## Code Style
|
||||
|
||||
### Imports
|
||||
|
||||
Three groups separated by blank lines: (1) stdlib, (2) external packages, (3) internal packages.
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Types/exports**: `PascalCase` (`ProductHandler`, `JWTManager`)
|
||||
- **Local variables**: `camelCase`
|
||||
- **Constructors**: `New<Type>` (`NewModel`, `NewJWTManager`, `NewClient`)
|
||||
- **Files**: `snake_case.go` (`ssh_auth.go`, `shipping_form.go`)
|
||||
- **JSON tags**: `snake_case` (`json:"roast_type"`, `json:"created_at"`)
|
||||
- **Error codes**: `PascalCase` constants (`ErrCodeValidation`)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Messages are lowercase, no trailing punctuation
|
||||
- Wrap errors with `%w`: `fmt.Errorf("failed to create user: %w", err)`
|
||||
- Use guard clauses with early returns in handlers
|
||||
- API errors use: `utils.RespondError(w, statusCode, "ERROR_CODE", "message", details)`
|
||||
- API success uses: `utils.RespondSuccess(w, statusCode, data)`
|
||||
|
||||
### Comments
|
||||
|
||||
- Doc comments start with the name of the thing: `// GenerateToken generates a new JWT token for a user`
|
||||
- Complete sentences with proper punctuation
|
||||
- Every exported type and function should have a doc comment
|
||||
|
||||
### Struct Tags
|
||||
|
||||
Always include `json` tags. Use GORM tags for database models.
|
||||
|
||||
```go
|
||||
type Coffee struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
RoastType string `gorm:"size:50;not null" json:"roast_type"`
|
||||
Price float64 `gorm:"not null" json:"price"`
|
||||
}
|
||||
```
|
||||
|
||||
### Function Ordering in Files
|
||||
|
||||
1. Exported types and constants
|
||||
2. Constructors (`New...`)
|
||||
3. Public methods (alphabetically)
|
||||
4. Private methods
|
||||
5. Helpers
|
||||
|
||||
### Context
|
||||
|
||||
Always pass `context.Context` as the first parameter in functions that need it.
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- Use stdlib `testing` package with `t.Errorf`/`t.Fatalf` (no testify)
|
||||
- HTTP tests use `httptest.NewRequest` and `httptest.NewRecorder`
|
||||
- Test DB setup: create temp SQLite file, connect, migrate, seed, defer cleanup
|
||||
- Always `defer os.Remove(testDB)` and `defer database.ResetForTesting()`
|
||||
- Test functions named `Test<FunctionName>` or `Test<FunctionName><Scenario>`
|
||||
|
||||
## TUI Architecture (Bubbletea / Elm Architecture)
|
||||
|
||||
- All state lives in `Model` struct in `pkg/tui/model.go`
|
||||
- Views follow **left-panel + right-panel** layout with `lipgloss.JoinHorizontal()`
|
||||
- Right panel auto-updates based on cursor position (no Enter/ESC to view details)
|
||||
- Left panel width is 18 chars; right panel fills remaining space
|
||||
- Each view has a builder: `BuildShopView()`, `BuildCartView()`, `BuildAccountView()`
|
||||
- Styling: active=#FFFFFF bold, inactive=#666666, accent=#4682B4
|
||||
- Cart uses `map[int]*CartItem` -- always sort via `GetCartItemsSlice()` before display
|
||||
- Reset `ScrollOffset = 0` when switching views
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The `terminal-shop-source/` directory is a read-only reference -- do not modify it
|
||||
- Environment config goes in `.env` (copy from `.env.example`); never commit `.env`
|
||||
- Read CLAUDE.md for detailed architecture decisions and common gotchas
|
||||
- Read MODELS.md for complete API specs, database schema, and design patterns
|
||||
202
CLAUDE.md
202
CLAUDE.md
@@ -1,202 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Terminal Coffee Shop is a terminal-based e-commerce application for ordering coffee, built with Go and the Bubbletea TUI framework. It's designed to eventually run as an SSH server similar to terminal.shop, with a backend API, PostgreSQL database, and Stripe payment integration.
|
||||
|
||||
**Current Status**: Phase 1 (Enhanced UI & Navigation) - The TUI is functional with shop, cart, and account views. Backend API has not been implemented yet.
|
||||
|
||||
## Essential Reading
|
||||
|
||||
Before making any changes, read these files in order:
|
||||
1. **README.md** - Complete roadmap and current phase status
|
||||
2. **MODELS.md** - Technical specifications, architecture, code standards, error handling patterns
|
||||
3. **CONTRIBUTING.md** - Workflow guidance for AI-assisted development
|
||||
|
||||
## Architecture
|
||||
|
||||
### Current Structure (TUI-only)
|
||||
|
||||
```
|
||||
terminalShop/
|
||||
├── main.go # SSH server entry point (Wish framework)
|
||||
├── pkg/
|
||||
│ ├── tui/ # Bubbletea TUI components
|
||||
│ │ ├── model.go # Main model with state management
|
||||
│ │ ├── views.go # View rendering and layout
|
||||
│ │ ├── shop.go # Shop view (left: coffee list, right: details)
|
||||
│ │ ├── cart.go # Cart view with scrolling
|
||||
│ │ ├── account.go # Account view (auto-updating right panel)
|
||||
│ │ ├── header.go # Tab navigation header
|
||||
│ │ ├── footer.go # Keybind hints
|
||||
│ │ └── breadcrumbs.go # Checkout flow breadcrumbs (cart only)
|
||||
│ └── models/
|
||||
│ └── coffee.go # Coffee and CartItem models
|
||||
└── .ssh/ # SSH host keys
|
||||
```
|
||||
|
||||
### Planned Structure (Backend)
|
||||
|
||||
The `api/` directory structure from MODELS.md will be created in Phase 2 with:
|
||||
- Chi router for HTTP endpoints
|
||||
- GORM models for PostgreSQL
|
||||
- JWT authentication middleware
|
||||
- Stripe payment integration
|
||||
|
||||
## Key Architectural Patterns
|
||||
|
||||
### TUI State Management (Bubbletea/Elm Architecture)
|
||||
|
||||
The `Model` struct in `pkg/tui/model.go` contains all application state:
|
||||
- Navigation state: `ViewingCart`, `ViewingAccount`, `CheckoutStep`
|
||||
- Cursor positions: `Cursor` (shop), `CartCursor` (cart), `AccountCursor` (account)
|
||||
- Data: `Coffees` (product list), `Cart` (map[int]*CartItem)
|
||||
- Scroll: `ScrollOffset` for handling overflow
|
||||
|
||||
**Critical Pattern**: All views (shop, cart, account) follow a **left-panel + right-panel layout**:
|
||||
- Left panel: List of items with cursor navigation (j/k)
|
||||
- Right panel: Details that auto-update based on cursor position
|
||||
- No Enter/ESC navigation - hovering updates the right panel immediately
|
||||
|
||||
### View Rendering Flow
|
||||
|
||||
1. `views.go:View()` - Main entry point, handles vertical centering and scrolling
|
||||
2. Calls view builders: `BuildShopView()`, `BuildCartView()`, `BuildAccountView()`
|
||||
3. Each view uses `lipgloss.JoinHorizontal()` for left+right layout
|
||||
4. Fixed content height calculated dynamically to prevent UI shifting
|
||||
|
||||
### Cart Management
|
||||
|
||||
**Important**: Cart uses `map[int]*models.CartItem` where the key is the coffee index in `m.Coffees`. This enables fast lookups but requires sorting in `GetCartItemsSlice()` because Go maps have randomized iteration order.
|
||||
|
||||
### Scrolling System
|
||||
|
||||
Implemented for cart view to handle long lists on small terminals:
|
||||
- `ScrollOffset` tracks current scroll position (line-based, not item-based)
|
||||
- Each cart item is ~5 lines tall (including borders)
|
||||
- Auto-scrolls when navigating with j/k to keep selected item visible
|
||||
- Manual scroll with pgup/pgdn
|
||||
|
||||
## Build and Run Commands
|
||||
|
||||
```bash
|
||||
# Build and run SSH server
|
||||
go build -o terminalshop && ./terminalshop
|
||||
# Server listens on localhost:23457
|
||||
# Connect with: ssh localhost -p 23457
|
||||
|
||||
# Quick run without building
|
||||
go run main.go
|
||||
|
||||
# Add dependencies
|
||||
go get <package>
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding a New View Component
|
||||
|
||||
1. Create new file in `pkg/tui/` (e.g., `shipping.go`)
|
||||
2. Add build function following the pattern: `func (m Model) BuildShippingView() string`
|
||||
3. Use left-panel + right-panel layout with `lipgloss.JoinHorizontal()`
|
||||
4. Update `views.go:View()` to conditionally render the new view
|
||||
5. Add navigation state to `Model` struct in `model.go`
|
||||
6. Add keybinds in `model.go:Update()`
|
||||
7. Update `footer.go` with new keybind hints
|
||||
|
||||
### Modifying Existing Views
|
||||
|
||||
The shop, cart, and account views are split across multiple files but all follow the same pattern:
|
||||
- Use `lipgloss.NewStyle()` for styling
|
||||
- Fixed widths for panels (leftWidth typically 18, rightWidth calculated)
|
||||
- Cursor-based selection with colored highlights
|
||||
- Responsive to `WindowWidth` and `WindowHeight`
|
||||
|
||||
### Working with the Model
|
||||
|
||||
When adding state to the Model struct:
|
||||
1. Add field to struct definition in `model.go`
|
||||
2. Initialize in `NewModel()` constructor
|
||||
3. Reset appropriately when changing views (see `case "s"`, `case "c"`, `case "a"`)
|
||||
4. Update in `Update()` function based on user input
|
||||
|
||||
## Important Implementation Details
|
||||
|
||||
### Styling Conventions
|
||||
|
||||
- Active/selected: White (#FFFFFF), bold
|
||||
- Inactive: Gray (#666666)
|
||||
- Accent color: Steel blue (#4682B4) - used for cart breadcrumbs and highlights
|
||||
- Coffee colors: Use individual coffee's `.Color` field (hex values like "#8B4513")
|
||||
|
||||
### Window Size Handling
|
||||
|
||||
`views.go` automatically adjusts content height based on terminal size:
|
||||
- Calculates available space after header, breadcrumbs, footer, margins
|
||||
- Minimum content height of 3 lines
|
||||
- Margins scale down on small windows (< 25 lines height)
|
||||
- Truncates content and enables scrolling when needed
|
||||
|
||||
### Breadcrumbs
|
||||
|
||||
Only visible in cart view, showing checkout flow: `cart / shipping / payment / confirmation`
|
||||
- Current step (tracked in `CheckoutStep`) is white/bold
|
||||
- Future steps are gray
|
||||
- Will be used when implementing checkout pages in later phases
|
||||
|
||||
### SSH Server
|
||||
|
||||
`main.go` uses Wish (Charm's SSH framework) to serve the TUI over SSH:
|
||||
- Host key in `.ssh/term_info_ed25519`
|
||||
- Port 23457
|
||||
- Each SSH session gets its own Bubbletea program instance
|
||||
- Username passed to `tui.NewModel(username)`
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Map iteration order**: Always sort cart items in `GetCartItemsSlice()` to prevent random reordering
|
||||
2. **Scroll offset**: When adding scrolling to new views, update viewport height calculation (currently hardcoded as `-9` for cart with breadcrumbs)
|
||||
3. **Content height**: Fixed height prevents UI shifting between pages but must account for all UI elements (header, breadcrumbs, footer, margins)
|
||||
4. **Panel widths**: Left panel is 18 chars, right panel is `WindowWidth - leftWidth - leftMargin - 4`
|
||||
5. **Reset scroll**: Always reset `ScrollOffset = 0` when switching views
|
||||
|
||||
## Code Style (from MODELS.md)
|
||||
|
||||
- Imports: stdlib, external deps, internal packages (separated by blank lines)
|
||||
- Error messages: lowercase, no punctuation, use `%w` for wrapping
|
||||
- Comments: Start with function/type name, complete sentences
|
||||
- Struct tags: Always include `json` tags with snake_case
|
||||
- Context: Always first parameter in functions
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
`terminal-shop-source/` contains the full terminal.shop codebase (TypeScript/Go hybrid):
|
||||
- `packages/go/pkg/tui/*.go` - TUI patterns and components
|
||||
- `packages/go/cmd/ssh/main.go` - SSH server setup
|
||||
- Study patterns, don't copy verbatim (different tech stack)
|
||||
|
||||
## Next Steps (from Roadmap)
|
||||
|
||||
Current phase is wrapping up. Next major phase:
|
||||
- **Phase 2**: Backend API Foundation with Chi router, PostgreSQL, GORM models
|
||||
- See MODELS.md for complete API specification and database schema
|
||||
- See README.md Phase 2 tasks for implementation order
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
- `DATABASE_URL` - PostgreSQL connection (for Phase 2)
|
||||
- `JWT_SECRET` - Auth token secret (for Phase 3)
|
||||
- `STRIPE_SECRET_KEY` / `STRIPE_PUBLIC_KEY` - Payment integration (for Phase 5)
|
||||
- `SSH_PORT` - Default 2222 (currently using 23457)
|
||||
|
||||
## Testing
|
||||
|
||||
Currently no tests. Testing standards defined in MODELS.md for future API implementation:
|
||||
- Unit tests for business logic
|
||||
- Integration tests for API endpoints
|
||||
- Use testify for assertions
|
||||
- Table-driven test pattern
|
||||
265
CONTRIBUTING.md
265
CONTRIBUTING.md
@@ -1,265 +0,0 @@
|
||||
# Contributing with Claude Code
|
||||
|
||||
This project is designed to be built incrementally with AI assistance (Claude Code). This guide explains how to effectively collaborate with Claude Code on this project.
|
||||
|
||||
## Quick Start for Claude Code Sessions
|
||||
|
||||
When starting a new Claude Code session, provide this context:
|
||||
|
||||
```
|
||||
I'm working on a terminal-based coffee shop app in Go. Please read README.md and MODELS.md to understand the project structure, current status, and technical standards before making any changes.
|
||||
```
|
||||
|
||||
## Project Documents - Read These First!
|
||||
|
||||
1. **README.md** - Project overview, roadmap, current status, and what to build next
|
||||
2. **MODELS.md** - Technical specs, architecture, code standards, error handling
|
||||
3. **terminal-shop-source/** - Reference implementation of terminal.shop (when stuck)
|
||||
|
||||
## How to Ask Claude Code for Help
|
||||
|
||||
### ✅ Good Prompts
|
||||
|
||||
**When starting a new feature:**
|
||||
```
|
||||
I want to implement Phase 1, Task 1.1 from the README - adding the account page view.
|
||||
Can you check the current model.go structure and add the account view following the
|
||||
patterns already established for shop and cart views?
|
||||
```
|
||||
|
||||
**When stuck on something:**
|
||||
```
|
||||
The cart isn't syncing properly between views. Can you check the cart state management
|
||||
in model.go and compare it to how terminal.shop handles this in their cart.go file?
|
||||
```
|
||||
|
||||
**When you want to follow best practices:**
|
||||
```
|
||||
I need to create the API endpoint for adding items to cart. Can you implement this
|
||||
following the error handling patterns and response format defined in MODELS.md?
|
||||
```
|
||||
|
||||
### ❌ Avoid Vague Prompts
|
||||
|
||||
```
|
||||
"Make the cart better" // Too vague - better what? Performance? UI? Features?
|
||||
"Add payment stuff" // Be specific - which phase? What functionality exactly?
|
||||
"Fix the bug" // What bug? Where? What's the expected behavior?
|
||||
```
|
||||
|
||||
## Workflow for Building Features
|
||||
|
||||
### Step 1: Identify the Task
|
||||
Look at README.md and find the current phase and next unchecked task:
|
||||
```
|
||||
## **PHASE 1: Enhanced UI & Navigation** (Current Phase)
|
||||
### Tasks
|
||||
#### 1.1 Add Account Page View
|
||||
- [ ] Add `viewingAccount bool` to model struct in `model.go`
|
||||
```
|
||||
|
||||
### Step 2: Reference the Specs
|
||||
Check MODELS.md for:
|
||||
- Code style rules
|
||||
- Error handling patterns
|
||||
- Similar examples
|
||||
- Architecture decisions
|
||||
|
||||
### Step 3: Ask Claude Code
|
||||
```
|
||||
I want to implement [specific task from README]. I've checked MODELS.md for
|
||||
the code standards. Can you implement this following the established patterns?
|
||||
```
|
||||
|
||||
### Step 4: Test and Verify
|
||||
```
|
||||
Run the app and test the new feature. If issues:
|
||||
"The account view isn't showing up when I press 'a'. Can you debug this?"
|
||||
```
|
||||
|
||||
### Step 5: Update Documentation
|
||||
```
|
||||
The account view is working! Can you check off the completed tasks in README.md?
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Starting a New Feature
|
||||
|
||||
1. Read the next task in README.md
|
||||
2. Check if there are related patterns in MODELS.md
|
||||
3. Look at terminal-shop-source for reference
|
||||
4. Ask Claude Code to implement following the patterns
|
||||
|
||||
**Example:**
|
||||
```
|
||||
I'm ready to start Phase 2 - Backend API Foundation. Let's begin with task 2.1
|
||||
"Project Setup". Can you create the directory structure and initialize the Chi
|
||||
router following the organization defined in MODELS.md?
|
||||
```
|
||||
|
||||
### Debugging Issues
|
||||
|
||||
Always provide context:
|
||||
- What you were trying to do
|
||||
- What you expected
|
||||
- What actually happened
|
||||
- Any error messages
|
||||
|
||||
**Example:**
|
||||
```
|
||||
When I add items to the cart and switch to the cart view, the items aren't showing.
|
||||
I expected to see the cart items listed. The console shows no errors. Can you check
|
||||
the cart state management in model.go?
|
||||
```
|
||||
|
||||
### Refactoring Code
|
||||
|
||||
Reference the style guide:
|
||||
|
||||
```
|
||||
The buildShopView function is getting long (300+ lines). Can you refactor it following
|
||||
the code organization principles in MODELS.md? Split it into smaller helper functions.
|
||||
```
|
||||
|
||||
### Adding API Endpoints
|
||||
|
||||
Be specific about the endpoint:
|
||||
|
||||
```
|
||||
I need to add the POST /api/v1/cart/items endpoint from the API specs in MODELS.md.
|
||||
Can you implement this with proper error handling and validation?
|
||||
```
|
||||
|
||||
## Using the Reference Implementation
|
||||
|
||||
The `terminal-shop-source/` directory contains terminal.shop's code. Use it when:
|
||||
|
||||
### When to Reference
|
||||
|
||||
1. **You need UI inspiration** - Look at their TUI components
|
||||
2. **You're stuck on Bubbletea patterns** - See how they handle state
|
||||
3. **You want to understand payment flow** - Check their payment.go
|
||||
4. **You need form examples** - Look at how they use `huh`
|
||||
|
||||
### How to Ask Claude Code to Reference It
|
||||
|
||||
```
|
||||
Can you look at how terminal.shop implements the payment form in
|
||||
terminal-shop-source/packages/go/pkg/tui/payment.go and create a similar
|
||||
pattern for our checkout flow?
|
||||
```
|
||||
|
||||
```
|
||||
I want to add breadcrumb navigation like terminal.shop has. Can you check their
|
||||
breadcrumbs.go implementation and adapt it to our project structure?
|
||||
```
|
||||
|
||||
## Best Practices for AI-Assisted Development
|
||||
|
||||
### 1. Be Incremental
|
||||
Build one small feature at a time. Don't try to implement entire phases at once.
|
||||
|
||||
✅ "Let's add the account view to the TUI"
|
||||
❌ "Implement the entire authentication system"
|
||||
|
||||
### 2. Verify After Each Change
|
||||
Run and test the code after each feature:
|
||||
```bash
|
||||
go run server.go model.go
|
||||
```
|
||||
|
||||
### 3. Keep Context Clear
|
||||
Start sessions with context, reference documents frequently:
|
||||
```
|
||||
Working on the cart API endpoints. I've checked MODELS.md for the API spec and
|
||||
error handling patterns. Ready to implement POST /api/v1/cart/items.
|
||||
```
|
||||
|
||||
### 4. Follow the Roadmap
|
||||
Stick to the phases in README.md. Don't skip ahead or work on random features.
|
||||
|
||||
### 5. Update Documentation
|
||||
When tasks are completed, update the checkboxes in README.md:
|
||||
```
|
||||
Can you mark task 1.1 "Add Account Page View" as complete in README.md?
|
||||
```
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### "Claude Code made changes that don't match the style guide"
|
||||
```
|
||||
I noticed the error messages aren't following the format in MODELS.md (they start
|
||||
with capitals). Can you fix them to match our error handling standards?
|
||||
```
|
||||
|
||||
### "The code doesn't follow the project structure"
|
||||
```
|
||||
The new handler was added to main.go but according to MODELS.md it should be in
|
||||
api/handlers/. Can you reorganize this to match our structure?
|
||||
```
|
||||
|
||||
### "I want to try a different approach"
|
||||
```
|
||||
Instead of using the pattern from MODELS.md, I want to try [your approach].
|
||||
Can you implement it this way and we'll compare?
|
||||
```
|
||||
|
||||
## Git Workflow
|
||||
|
||||
### Before Committing
|
||||
- [ ] Code follows MODELS.md standards
|
||||
- [ ] Tests pass (when applicable)
|
||||
- [ ] Documentation updated (README checkboxes)
|
||||
- [ ] No sensitive data (check .gitignore)
|
||||
|
||||
### Commit Messages
|
||||
Use conventional commits:
|
||||
```bash
|
||||
feat: add account page view to TUI
|
||||
fix: cart items not displaying correctly
|
||||
refactor: split buildShopView into helper functions
|
||||
docs: update README with completed tasks
|
||||
```
|
||||
|
||||
## Tips for Effective Sessions
|
||||
|
||||
1. **Start each session by providing context** about what you're working on
|
||||
2. **Reference the docs** (README.md, MODELS.md) frequently
|
||||
3. **Ask Claude Code to check the reference implementation** when uncertain
|
||||
4. **Build incrementally** - one feature at a time
|
||||
5. **Test frequently** - run the app after each change
|
||||
6. **Update the README** - check off completed tasks
|
||||
7. **Follow the roadmap** - stick to the phases in order
|
||||
|
||||
## Example Full Session Flow
|
||||
|
||||
```
|
||||
Session start:
|
||||
"I'm working on Terminal Coffee Shop. Last session we completed the cart view.
|
||||
Today I want to start Phase 1, Task 1.1 - adding the account page. Can you read
|
||||
README.md to see what needs to be done?"
|
||||
|
||||
Implementation:
|
||||
"Great! Now implement the account view following the same pattern as buildShopView
|
||||
and buildCartView in model.go. Add the viewingAccount bool and the keybind handling."
|
||||
|
||||
Testing:
|
||||
"I ran it but pressing 'a' doesn't switch to account view. Can you debug?"
|
||||
|
||||
Completion:
|
||||
"Perfect! It's working now. Can you update README.md to check off all the tasks
|
||||
we completed for 1.1?"
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Bubbletea Tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials)
|
||||
- [Lipgloss Examples](https://github.com/charmbracelet/lipgloss/tree/master/examples)
|
||||
- [Chi Router Guide](https://go-chi.io/#/pages/getting_started)
|
||||
- [GORM Documentation](https://gorm.io/docs/)
|
||||
- [Stripe Go SDK](https://github.com/stripe/stripe-go)
|
||||
|
||||
---
|
||||
|
||||
**Remember**: The goal is to build this incrementally and learn along the way. Don't rush through phases. Take time to understand each pattern before moving on!
|
||||
@@ -4,10 +4,10 @@ RUN apk add --no-cache gcc musl-dev
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o api ./api/main.go
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /tmp/api ./api/main.go
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /app/api .
|
||||
COPY --from=builder /tmp/api ./api
|
||||
CMD ["./api"]
|
||||
|
||||
55
Makefile
Normal file
55
Makefile
Normal file
@@ -0,0 +1,55 @@
|
||||
.PHONY: test test-pretty test-watch test-ci test-summary cover
|
||||
|
||||
PKGS ?= ./...
|
||||
JSON_OUT ?= test-output.json
|
||||
JUNIT ?= test-report.xml
|
||||
COVER ?= coverage.out
|
||||
|
||||
# Plain go test. Fastest, no extra deps loaded
|
||||
test:
|
||||
go test $(PKGS)
|
||||
|
||||
# Live pretty output. Groups by test name, reruns flakes once.
|
||||
# Hides gotestsum's plain-text summary; tparse renders failures instead.
|
||||
test-pretty:
|
||||
@set +e; \
|
||||
go tool gotestsum \
|
||||
--format testname \
|
||||
--format-icons hivis \
|
||||
--hide-summary=skipped,output,errors,failed \
|
||||
--rerun-fails=1 \
|
||||
--jsonfile $(JSON_OUT) \
|
||||
--packages="$(PKGS)" \
|
||||
-- -count=1; \
|
||||
rc=$$?; \
|
||||
echo; \
|
||||
tparse -all -notests -file=$(JSON_OUT) || true; \
|
||||
exit $$rc
|
||||
|
||||
# TDD loop: re-runs affected packages on file save.
|
||||
test-watch:
|
||||
go tool gotestsum --watch --format testname --packages="$(PKGS)"
|
||||
|
||||
# CI / pre-push: race + coverage + JSON + JUnit + tparse summary table
|
||||
test-ci:
|
||||
@set +e; \
|
||||
go tool gotestsum \
|
||||
--format pkgname \
|
||||
--format-icons hivis \
|
||||
--hide-summary=skipped,output,errors,failed \
|
||||
--jsonfile $(JSON_OUT) \
|
||||
--junitfile $(JUNIT) \
|
||||
--packages="$(PKGS)" \
|
||||
-- -count=1 -race -covermode=atomic -coverprofile=$(COVER); \
|
||||
rc=$$?; \
|
||||
echo; \
|
||||
tparse -all -notests -slow 10 -file=$(JSON_OUT) || true; \
|
||||
exit $$rc
|
||||
|
||||
# Replay last summary without re-running tests
|
||||
test-summary:
|
||||
tparse -all -notests -file=$(JSON_OUT)
|
||||
|
||||
# Open HTML coverage report from last `test-ci` run
|
||||
cover:
|
||||
go tool cover -html=$(COVER)
|
||||
114
README.md
114
README.md
@@ -1,62 +1,74 @@
|
||||
# Terminal Coffee Shop
|
||||
|
||||
A terminal-based coffee shop, accessible over SSH. Built with Go, Bubbletea, Wish, Chi, PostgreSQL, Stripe, and Shippo.
|
||||
|
||||
```bash
|
||||
ssh localhost -p 23456
|
||||
ssh sshops.uk
|
||||
```
|
||||
|
||||
## Running locally
|
||||
## demo
|
||||
https://github.com/user-attachments/assets/daf895ac-2c01-4323-bf27-8d7829b9ddd5
|
||||
|
||||
it's a coffee shop but in your terminal. you ssh in, browse the menu, chuck stuff in your cart, put in your address and card, and buy coffee without ever touching a browser. your ssh key is your account so there's no login or signup or any of that.
|
||||
|
||||
made it because i thought it would be fun to see if you could build a real e-commerce flow that lives entirely in a terminal. turns out you can. heavily inspired by [terminal.shop](https://terminal.shop).
|
||||
|
||||
## what it actually does
|
||||
|
||||
ssh in and you get a TUI. browse coffee, add to cart, save addresses, save cards. checkout charges your card via stripe and creates a real order. addresses get validated against shippo (US) or bring (Norway) before they're allowed in. cards are tokenized server side so the secret key never touches your terminal session. adding cards through the tui is coming soon, i just need to register a business and i have no money. webhooks from stripe reconcile order state if anything goes weird mid charge, and a cron job catches orders that got charged but never recorded because the server crashed.
|
||||
|
||||
it's a real shop. you can actually buy coffee.
|
||||
|
||||
## the stack
|
||||
|
||||
**TUI + SSH.** Go with [Bubbletea](https://github.com/charmbracelet/bubbletea) for the terminal UI, [Wish](https://github.com/charmbracelet/wish) for the ssh server. every ssh session spawns its own bubbletea program. lipgloss for styling.
|
||||
|
||||
**API.** Go again, [Chi](https://github.com/go-chi/chi) for routing. JWT auth where the token is exchanged for an ssh fingerprint, so the TUI authenticates by proving it has the same key the user ssh'd in with. no passwords anywhere.
|
||||
|
||||
**Database.** SQLite via GORM. yeah just a file. it's fine. i back it up nightly with restic and the SQLite backup API so it's WAL safe even if a write is in flight. when traffic actually demands postgres i'll switch. the DSN prefix logic in `pkg/database/db.go` already handles both drivers.
|
||||
|
||||
**Payments.** [Stripe](https://stripe.com). cards are tokenized server side, never raw card data on disk. webhooks handle async confirmations like 3DS, deferred charges, refunds. idempotency keys tied to order IDs so retries can't double charge.
|
||||
|
||||
**Address validation.** [Shippo](https://goshippo.com) for US addresses, [Bring](https://www.bring.com) for Norway. invalid addresses get rejected before they hit the cart.
|
||||
|
||||
## where it runs
|
||||
|
||||
production lives on a single Hetzner VPS in Helsinki, behind [Caddy](https://caddyserver.com) which handles auto TLS so there's no nginx config to babysit. everything in docker compose. the API, caddy, and the whole observability stack run as separate compose stacks on the same box. public DNS only resolves the API and the marketing site, everything else is tailnet only.
|
||||
|
||||
backups go to a second machine sitting in my house. that machine runs **Linux From Scratch** with a hand compiled kernel and a hand rolled ports repo i maintain myself with [scratchpkg](https://github.com/emmett1/scratchpkg). no docker, no apt, every binary built from a recipe i wrote. it also runs my self hosted [Gitea](https://gitea.com). it's got like 4 GB of RAM so it has to run lean. picked it because i wanted to learn how a linux box actually fits together when nobody's holding your hand.
|
||||
|
||||
backup pipeline runs nightly. cron on the Hetzner box snapshots the SQLite db via the backup API, ships it over Tailscale SFTP to the LFS box with [restic](https://restic.net), encrypted and deduped. dual dead man switches, [healthchecks.io](https://healthchecks.io) and a Uptime Kuma push monitor, both go off if the cron silently dies.
|
||||
|
||||
## observability
|
||||
|
||||
probably the over engineered part. honestly built it because i wanted to learn the stack.
|
||||
|
||||
**Prometheus** scrapes the API for the usual RED metrics plus custom counters like cart conversions, auth attempts, stripe events, log levels, orders created.
|
||||
|
||||
**Grafana** sits in front of everything. tailnet only, no public DNS.
|
||||
|
||||
**Loki + Promtail** for logs. promtail auto discovers every container via docker labels so anything new on the box is logged for free.
|
||||
|
||||
**Tempo** for traces. the API exports OTLP via [otelchi](https://github.com/riandyrn/otelchi) on the inbound side and [otelhttp](https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp) on outbound calls to stripe, shippo, bring. the GORM tracing plugin emits a span per SQL query.
|
||||
|
||||
**Uptime Kuma** for blackbox checks. API health, TLS, backup target reachable.
|
||||
|
||||
**Alerts** route through Grafana to a ntfy webhook to my phone.
|
||||
|
||||
end to end, a request to `/api/v1/cart` shows up as one trace with the chi handler as the root, gorm queries as children, and outbound HTTP to stripe as a sibling. all one tempo span tree.
|
||||
|
||||
## local dev
|
||||
|
||||
```bash
|
||||
# Copy and fill in env vars
|
||||
cp .env.example .env
|
||||
|
||||
# Start the API server
|
||||
go run api/main.go
|
||||
|
||||
# Start the SSH server
|
||||
go run main.go
|
||||
git clone https://github.com/marcusDenslow/terminalShop
|
||||
cd terminalShop
|
||||
cp .env.example .env # fill in stripe test keys
|
||||
go run main.go # ssh server on :23457
|
||||
go run api/main.go # API on :8080
|
||||
ssh localhost -p 23457
|
||||
```
|
||||
|
||||
## Stack
|
||||
uses SQLite by default so there's nothing to spin up.
|
||||
|
||||
| Layer | Tech |
|
||||
|-------|------|
|
||||
| TUI | Bubbletea + Wish (SSH) |
|
||||
| HTTP API | Chi router |
|
||||
| Database | PostgreSQL + GORM |
|
||||
| Auth | SSH key fingerprint + JWT |
|
||||
| Payments | Stripe |
|
||||
| Shipping | Shippo |
|
||||
## inspiration
|
||||
|
||||
## What's working
|
||||
|
||||
- SSH server — key-based auth, auto user creation
|
||||
- Shop page — browse products with live detail panel
|
||||
- Cart — add/remove items, synced to API
|
||||
- Checkout flow — shipping address → payment → confirmation
|
||||
- Saved addresses and cards
|
||||
- Order history in account view
|
||||
- FAQ page
|
||||
- Responsive layout (small/medium/large terminal sizes)
|
||||
- Token refresh
|
||||
|
||||
## TODO
|
||||
|
||||
**Features missing vs reference (terminal.shop):**
|
||||
- [ ] Splash/loading screen on connect
|
||||
- [ ] Animated logo with blinking cursor
|
||||
- [ ] API token management page
|
||||
- [ ] OAuth app management page
|
||||
- [ ] About page
|
||||
- [ ] Theme system — currently 40+ hardcoded hex colors scattered across files
|
||||
- [ ] Viewport-based scrolling — currently manual scroll math with hardcoded heights
|
||||
|
||||
**Known bugs:**
|
||||
- [ ] Address delete slice bug in `shipping_form.go:386` — `[m.AddressCursor+1:]...` missing
|
||||
- [ ] Account page has a placeholder "something" menu item in `models/coffee.go`
|
||||
|
||||
**Code quality:**
|
||||
- [ ] Nested state structs (reference uses `state.shop`, `state.cart`, etc. vs flat Model)
|
||||
- [ ] Centralized styles file
|
||||
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
|
||||
# something
|
||||
|
||||
141
REFACTORING.md
141
REFACTORING.md
@@ -1,141 +0,0 @@
|
||||
# Refactoring Complete!
|
||||
|
||||
The codebase has been refactored from a single `model.go` file (~650 lines) into a clean, modular structure.
|
||||
|
||||
## New Structure
|
||||
|
||||
```
|
||||
terminalShop/
|
||||
├── main.go # Entry point (~20 lines)
|
||||
├── pkg/
|
||||
│ ├── models/
|
||||
│ │ └── coffee.go # Data models (~25 lines)
|
||||
│ └── tui/
|
||||
│ ├── model.go # Core model & state (~160 lines)
|
||||
│ ├── views.go # Main View() function (~60 lines)
|
||||
│ ├── shop.go # Shop view (~90 lines)
|
||||
│ ├── cart.go # Cart view (~90 lines)
|
||||
│ ├── account.go # Account view (~75 lines)
|
||||
│ ├── header.go # Header rendering (~95 lines)
|
||||
│ └── footer.go # Footer rendering (~75 lines)
|
||||
├── model.go.old # Backup of old file
|
||||
└── server.go.old # Backup of old file
|
||||
```
|
||||
|
||||
## What Changed
|
||||
|
||||
### Before
|
||||
- **1 file**: `model.go` with 650+ lines
|
||||
- All code mixed together
|
||||
- Hard to navigate and maintain
|
||||
- Difficult to find specific features
|
||||
|
||||
### After
|
||||
- **8 focused files** averaging ~70 lines each
|
||||
- Clear separation of concerns
|
||||
- Easy to find and modify features
|
||||
- Follows Go best practices
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
| File | Purpose | Key Functions |
|
||||
|------|---------|---------------|
|
||||
| `main.go` | App entry point | `main()` |
|
||||
| `pkg/models/coffee.go` | Data structures | `Coffee`, `CartItem`, `AccountMenuItems` |
|
||||
| `pkg/tui/model.go` | State management | `Model`, `Init()`, `Update()`, `NewModel()` |
|
||||
| `pkg/tui/views.go` | Layout assembly | `View()` |
|
||||
| `pkg/tui/shop.go` | Shop view | `BuildShopView()` |
|
||||
| `pkg/tui/cart.go` | Cart view | `BuildCartView()` |
|
||||
| `pkg/tui/account.go` | Account views | `BuildAccountView()`, `buildAccountSubpage()` |
|
||||
| `pkg/tui/header.go` | Header/tabs | `BuildHeader()` |
|
||||
| `pkg/tui/footer.go` | Footer/keybinds | `BuildFooter()` |
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Easier to Navigate**: Find shop code in `shop.go`, cart code in `cart.go`, etc.
|
||||
2. **Easier to Test**: Each component can be tested independently
|
||||
3. **Easier to Extend**: Add new views by creating new files
|
||||
4. **Better Organization**: Related code stays together
|
||||
5. **Cleaner Git Diffs**: Changes to cart don't affect shop code
|
||||
6. **Follows Standards**: Matches Go project conventions
|
||||
|
||||
## Running the App
|
||||
|
||||
Same as before:
|
||||
|
||||
```bash
|
||||
# Run the app
|
||||
go run main.go
|
||||
|
||||
# Or build and run
|
||||
go build -o terminalshop main.go
|
||||
./terminalshop
|
||||
```
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### Adding a New View (e.g., "Settings")
|
||||
|
||||
1. Create `pkg/tui/settings.go`:
|
||||
```go
|
||||
package tui
|
||||
|
||||
func (m Model) BuildSettingsView() string {
|
||||
// Your settings view code
|
||||
}
|
||||
```
|
||||
|
||||
2. Update `model.go`:
|
||||
- Add `viewingSettings bool` to Model struct
|
||||
- Add keybind case in Update()
|
||||
|
||||
3. Update `views.go`:
|
||||
- Add condition for settings view
|
||||
|
||||
4. Update `header.go` and `footer.go`:
|
||||
- Add settings tab/keybinds
|
||||
|
||||
### Adding New Data Models
|
||||
|
||||
Add to `pkg/models/` directory:
|
||||
```go
|
||||
// pkg/models/order.go
|
||||
package models
|
||||
|
||||
type Order struct {
|
||||
ID string
|
||||
Items []CartItem
|
||||
Total float64
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- Old files backed up as `.old`
|
||||
- All functionality preserved
|
||||
- No behavior changes
|
||||
- Imports use `terminalShop/pkg/...`
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that the code is organized, you can easily:
|
||||
- Add new views (checkout, payment, orders)
|
||||
- Implement API client in `pkg/api/`
|
||||
- Add database models in `pkg/models/`
|
||||
- Create tests for each component
|
||||
- Continue with Phase 2 of the roadmap (Backend API)
|
||||
|
||||
## Rollback
|
||||
|
||||
If needed, restore the old structure:
|
||||
```bash
|
||||
mv model.go.old model.go
|
||||
mv server.go.old server.go
|
||||
rm -rf pkg/
|
||||
# Edit main.go to use InitialModel again
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Refactored on**: 2026-02-12
|
||||
**Lines of code**: Same functionality, better organized!
|
||||
80
TODO.md
80
TODO.md
@@ -1,80 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## Done
|
||||
- [x] Cart persistence across sessions
|
||||
- [x] Token refresh — auto-refresh JWT before expiry
|
||||
- [x] Help screen — press ? to show keybindings
|
||||
- [x] Order detail view — press Enter on an order to see full details
|
||||
- [x] FAQ page
|
||||
- [x] About page
|
||||
- [x] Splash screen — logo + auth + data preload on connect
|
||||
- [x] Bulk data preload — /view/init endpoint fetches all user data at once
|
||||
- [x] Stripe webhook — payment_intent.succeeded/failed sync order status
|
||||
- [x] PCI-safe card tokenization — publishable key in SSH process, raw card data never reaches backend
|
||||
- [x] Rate limiting — per-IP and per-user limits on sensitive endpoints
|
||||
- [x] Audit logging — structured JSON logs for all financial events
|
||||
- [x] Shop page — dual viewport (menu left, detail right), matches reference shop.go
|
||||
- [x] Cart page — single viewport with proper scroll
|
||||
- [x] Shipping page — single viewport
|
||||
- [x] Payment page — single viewport
|
||||
- [x] Confirm page — single viewport (uses DefaultKeyMap for pgup/pgdn scrolling)
|
||||
---
|
||||
|
||||
## Do First (meaningful gaps vs reference)
|
||||
|
||||
### Product variants
|
||||
The reference supports multiple variants per product (roast type, grind, size) with
|
||||
separate prices. We treat every product as single-variant. Without this the shop is
|
||||
very limited once you have real products.
|
||||
- [ ] Variant model + DB migration
|
||||
- [ ] API: return variants in /products response
|
||||
- [ ] TUI: show variant selector in shop detail panel
|
||||
|
||||
### User profile editing
|
||||
Users currently cannot update their name or email from inside the app.
|
||||
- [ ] PUT /api/v1/profile endpoint
|
||||
- [ ] TUI: editable name/email fields in account view
|
||||
|
||||
### Missing API endpoints
|
||||
- [ ] PUT /api/v1/addresses/:id — update a saved address
|
||||
- [ ] GET /api/v1/orders/:id — single order (needed for better order detail view)
|
||||
|
||||
---
|
||||
|
||||
## Polish (noticeable but not blocking)
|
||||
|
||||
- [ ] Animated cursor — reference has a blinking block cursor on the splash/logo
|
||||
(cursor.go, CursorTickMsg, 700ms interval). Small but gives the app personality.
|
||||
- [ ] Themed UI — reference has a theme/ package; we use hardcoded hex colors everywhere.
|
||||
Makes it hard to change the look later.
|
||||
- [ ] Shipping timeframe — Shippo returns estimated delivery window; show it on confirm page
|
||||
- [ ] Loading spinner — replace plain "loading..." text with animated spinner
|
||||
- [ ] HTTPS/QR payment option — third card-entry method, generates a one-time URL + QR code
|
||||
so users can enter card details in their browser instead of over SSH.
|
||||
Reference: payment.go paymentHttpsView, PollPaymentInitMsg/StatusMsg/CompleteMsg.
|
||||
- [ ] Per-field card validation — use huh .Validate() on each form field so errors
|
||||
show inline as user tabs through, instead of a single error on submit.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] API client tests — pkg/api/client.go has no tests
|
||||
- [ ] Card handler tests — api/handlers/cards.go
|
||||
- [ ] Order handler tests — api/handlers/orders.go
|
||||
- [ ] Address handler tests — api/handlers/addresses.go (partial)
|
||||
- [ ] Webhook handler tests — api/handlers/webhook.go
|
||||
- [ ] TUI payment form tests — validatePaymentForm() is pure and easy to unit test
|
||||
|
||||
---
|
||||
|
||||
## Post-Launch
|
||||
|
||||
- [ ] Shippo webhook — order tracking status updates pushed from Shippo
|
||||
- [ ] Email system — order confirmation + shipped notification
|
||||
- [ ] Subscription model — DB table, CRUD endpoints, TUI flow (select → shipping → payment → confirm)
|
||||
- [ ] Subscription management — list, detail, cancel with confirmation in account view
|
||||
- [ ] Personal access tokens — model, CRUD API, TUI management in account view
|
||||
- [ ] OAuth app management — model, CRUD API, TUI create/list/delete
|
||||
- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle
|
||||
- [ ] Cron jobs — subscription processing, inventory tracking, unshipped alerts
|
||||
BIN
api/api-server
BIN
api/api-server
Binary file not shown.
3
api/doc.go
Normal file
3
api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Command api is the HTTP backend: it boots the router, bolts on all the
|
||||
// middleware, and answers the requests the TUI throws at it.
|
||||
package main
|
||||
161
api/handlers/account.go
Normal file
161
api/handlers/account.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
// AccountHandler serves JWT-authed, customer-facing account-settings endpoints.
|
||||
// Unlike AdminUserHandler (X-Admin-Key gated), every route here edits the
|
||||
// logged-in user's own row, identified by the JWT user id in context.
|
||||
type AccountHandler struct {
|
||||
// maxOrderCents is the global spend cap, needed to bound a self-limit: a
|
||||
// customer may only TIGHTEN their effective ceiling, never raise it.
|
||||
maxOrderCents int
|
||||
}
|
||||
|
||||
// NewAccountHandler constructs an AccountHandler. maxOrderCents is the global
|
||||
// MAX_ORDER_CENTS, wired from cfg in routes.SetupRoutes (same value the cart
|
||||
// handler receives).
|
||||
func NewAccountHandler(maxOrderCents int) *AccountHandler {
|
||||
return &AccountHandler{maxOrderCents: maxOrderCents}
|
||||
}
|
||||
|
||||
// SetSpendLimit sets or clears the logged-in user's self-service spend limit
|
||||
// (User.SelfLimitCents). Body: {"self_limit_cents": <int|null>}.
|
||||
// - null -> clears the self-limit; the user reverts to the admin/global cap
|
||||
// - 0 -> a real "block everything" ceiling (NOT an off-switch; that is the
|
||||
// whole point of a customer-set limit — it can only tighten)
|
||||
// - >0 -> custom ceiling, must be <= the effective admin/global cap
|
||||
//
|
||||
// Security: this endpoint is JWT-authed and edits the caller's OWN row, so a
|
||||
// self-limit is LOWER-ONLY. A value above the user's effective admin/global cap
|
||||
// is rejected here (HTTP 400) so a compromised session cannot lift its own
|
||||
// ceiling; the min() fold in cart.go:ConvertCart is the authoritative second
|
||||
// guard. Negative values are rejected at the write boundary (the ConvertCart
|
||||
// read path also ignores a stray negative from a direct DB write).
|
||||
//
|
||||
// Mirrors AdminUserHandler.SetUserOrderCap but reads the user id from the JWT
|
||||
// context instead of a {id} URL param, uses a distinct body key + audit event,
|
||||
// and gates the value against the cap.
|
||||
func (h *AccountHandler) SetSpendLimit(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
rawLimit, ok := raw["self_limit_cents"]
|
||||
if !ok {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "self_limit_cents is required (use null to clear the limit)", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var limitCents *int
|
||||
if string(rawLimit) != "null" {
|
||||
var n int
|
||||
if err := json.Unmarshal(rawLimit, &n); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "self_limit_cents must be an integer or null", nil)
|
||||
return
|
||||
}
|
||||
if n < 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_LIMIT", "self_limit_cents must be >= 0 (use null to clear the limit)", nil)
|
||||
return
|
||||
}
|
||||
limitCents = &n
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// A self-limit may only tighten the effective cap, never raise it. Compute
|
||||
// the user's effective admin/global ceiling exactly as cart.go:ConvertCart
|
||||
// does (per-user override wins when present and non-negative; an active cap
|
||||
// is > 0). Reject any self-limit above an ACTIVE admin ceiling. When no admin
|
||||
// ceiling is active (global disabled and no override) there is nothing to
|
||||
// raise, so any non-negative self-limit is allowed — it can only ADD a cap.
|
||||
if limitCents != nil {
|
||||
adminCap := h.maxOrderCents
|
||||
if user.MaxOrderCents != nil && *user.MaxOrderCents >= 0 {
|
||||
adminCap = *user.MaxOrderCents
|
||||
}
|
||||
if adminCap > 0 && *limitCents > adminCap {
|
||||
utils.RespondError(w, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", "self_limit_cents may not exceed your account spend cap", map[string]any{"self_limit_cents": *limitCents, "cap_cents": adminCap})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Map form so a nil pointer persists SQL NULL; a struct Updates would skip it.
|
||||
if err := db.Model(&user).Updates(map[string]any{"self_limit_cents": limitCents}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update spend limit", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.UserSelfLimitSet(user.ID, limitCents)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user_id": user.ID,
|
||||
"self_limit_cents": limitCents,
|
||||
})
|
||||
}
|
||||
|
||||
// SetPrivacyMode sets the logged-in user's account-level privacy default
|
||||
// (User.PrivacyMode). Body: {"privacy_mode": <bool>}.
|
||||
//
|
||||
// PrivacyMode is a SOFT default, not a server-enforced guarantee: it only
|
||||
// pre-selects "don't keep" at checkout in the TUI. The checkout still sends an
|
||||
// explicit per-order intent which the backend honors (cart.go:ConvertCart) —
|
||||
// that is what lets a customer override the default with "save anyway". A
|
||||
// stricter server-enforced "never save" would be a separate mode.
|
||||
//
|
||||
// Mirrors SetSpendLimit: JWT-authed, edits the caller's OWN row.
|
||||
func (h *AccountHandler) SetPrivacyMode(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
rawMode, ok := raw["privacy_mode"]
|
||||
if !ok {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "privacy_mode is required", nil)
|
||||
return
|
||||
}
|
||||
var enabled bool
|
||||
if err := json.Unmarshal(rawMode, &enabled); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "privacy_mode must be a boolean", 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)
|
||||
return
|
||||
}
|
||||
|
||||
// Map form so an explicit false persists (a struct Updates skips a zero bool)
|
||||
if err := db.Model(&user).Updates(map[string]any{"privacy_mode": enabled}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update privacy mode", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.UserPrivacyModeSet(user.ID, enabled)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user_id": user.ID,
|
||||
"privacy_mode": enabled,
|
||||
})
|
||||
}
|
||||
61
api/handlers/account_privacy_test.go
Normal file
61
api/handlers/account_privacy_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// TestSetPrivacyMode covers the account privacy toggle: enable, disable (a real
|
||||
// true->false transition, which the map-form Updates must persist), and the two
|
||||
// 400 paths (missing field, wrong type).
|
||||
func TestSetPrivacyMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantStatus int
|
||||
wantValue bool
|
||||
}{
|
||||
{"enable", `{"privacy_mode": true}`, http.StatusOK, true},
|
||||
{"disable", `{"privacy_mode": false}`, http.StatusOK, false},
|
||||
{"missing field", `{}`, http.StatusBadRequest, false},
|
||||
{"wrong type", `{"privacy_mode": "yes"}`, http.StatusBadRequest, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
// Seed the opposite so an OK case proves a real transition (and the
|
||||
// disable case actually exercises persisting an explicit false).
|
||||
if tc.wantStatus == http.StatusOK {
|
||||
if err := database.GetDB().Model(&user).Update("privacy_mode", !tc.wantValue).Error; err != nil {
|
||||
t.Fatalf("seed privacy_mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
NewAccountHandler(0).SetPrivacyMode(
|
||||
w, authRequest("PUT", "/api/v1/account/privacy-mode", []byte(tc.body), user.ID))
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Fatalf("status: want %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String())
|
||||
}
|
||||
if tc.wantStatus != http.StatusOK {
|
||||
return
|
||||
}
|
||||
var reloaded models.User
|
||||
if err := database.GetDB().First(&reloaded, user.ID).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if reloaded.PrivacyMode != tc.wantValue {
|
||||
t.Errorf("privacy_mode: want %v, got %v", tc.wantValue, reloaded.PrivacyMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
118
api/handlers/account_test.go
Normal file
118
api/handlers/account_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func TestSetSpendLimit(t *testing.T) {
|
||||
const globalCap = 5000
|
||||
cases := []struct {
|
||||
name string
|
||||
seedSelf *int // starting SelfLimitCents on the row
|
||||
seedAdmin *int // per-user MaxOrderCents override (effective admin cap)
|
||||
body string
|
||||
wantStatus int
|
||||
wantCode string // error code; "" means success
|
||||
wantLimit *int // expected SelfLimitCents after a successful update
|
||||
}{
|
||||
{"set positive under cap", nil, nil, `{"self_limit_cents":2000}`, http.StatusOK, "", intPtr(2000)},
|
||||
{"set zero blocks all", nil, nil, `{"self_limit_cents":0}`, http.StatusOK, "", intPtr(0)},
|
||||
{"set equal to cap", nil, nil, `{"self_limit_cents":5000}`, http.StatusOK, "", intPtr(5000)},
|
||||
{"clear with null", intPtr(2000), nil, `{"self_limit_cents":null}`, http.StatusOK, "", nil},
|
||||
{"overwrite existing", intPtr(2000), nil, `{"self_limit_cents":1000}`, http.StatusOK, "", intPtr(1000)},
|
||||
{"above global cap rejected", nil, nil, `{"self_limit_cents":6000}`, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", nil},
|
||||
{"under per-user override accepted", nil, intPtr(10000), `{"self_limit_cents":8000}`, http.StatusOK, "", intPtr(8000)},
|
||||
{"above per-user override rejected", nil, intPtr(10000), `{"self_limit_cents":12000}`, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", nil},
|
||||
{"negative rejected", nil, nil, `{"self_limit_cents":-1}`, http.StatusBadRequest, "INVALID_LIMIT", nil},
|
||||
{"missing field rejected", nil, nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
|
||||
{"non-integer rejected", nil, nil, `{"self_limit_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
|
||||
{"malformed json rejected", nil, nil, `{`, http.StatusBadRequest, "INVALID_BODY", nil},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
db := database.GetDB()
|
||||
if tc.seedSelf != nil {
|
||||
if err := db.Model(&user).Update("self_limit_cents", *tc.seedSelf).Error; err != nil {
|
||||
t.Fatalf("seed self limit: %v", err)
|
||||
}
|
||||
}
|
||||
if tc.seedAdmin != nil {
|
||||
if err := db.Model(&user).Update("max_order_cents", *tc.seedAdmin).Error; err != nil {
|
||||
t.Fatalf("seed admin cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewAccountHandler(globalCap)
|
||||
req := authRequest("PUT", "/api/v1/account/spend-limit", []byte(tc.body), user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetSpendLimit(w, req)
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
|
||||
}
|
||||
assertErrCode(t, w, tc.wantCode)
|
||||
|
||||
var got models.User
|
||||
if err := db.First(&got, user.ID).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if tc.wantStatus == http.StatusOK {
|
||||
if !eqIntPtr(got.SelfLimitCents, tc.wantLimit) {
|
||||
t.Errorf("db SelfLimitCents: want %s got %s", fmtIntPtr(tc.wantLimit), fmtIntPtr(got.SelfLimitCents))
|
||||
}
|
||||
} else if !eqIntPtr(got.SelfLimitCents, tc.seedSelf) {
|
||||
// A rejected request must not mutate the column.
|
||||
t.Errorf("rejected request changed db limit: want %s got %s", fmtIntPtr(tc.seedSelf), fmtIntPtr(got.SelfLimitCents))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSpendLimit_CapDisabledAllowsAnyLimit: with no active admin ceiling
|
||||
// (global 0, no override) any non-negative self-limit is accepted — a self-limit
|
||||
// can only ADD a ceiling, so there is nothing to raise.
|
||||
func TestSetSpendLimit_CapDisabledAllowsAnyLimit(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
handler := NewAccountHandler(0) // global cap disabled
|
||||
req := authRequest("PUT", "/api/v1/account/spend-limit", []byte(`{"self_limit_cents":999999}`), user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetSpendLimit(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200 got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var got models.User
|
||||
if err := database.GetDB().First(&got, user.ID).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if !eqIntPtr(got.SelfLimitCents, intPtr(999999)) {
|
||||
t.Errorf("db SelfLimitCents: want 999999 got %s", fmtIntPtr(got.SelfLimitCents))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSpendLimit_RequireAuthGate exercises the JWT gate through a real
|
||||
// router: no user context => 401, the handler is never reached.
|
||||
func TestSetSpendLimit_RequireAuthGate(t *testing.T) {
|
||||
setupOrderTestDB(t)
|
||||
router := chi.NewRouter()
|
||||
router.With(middleware.RequireAuth).
|
||||
Put("/api/v1/account/spend-limit", NewAccountHandler(5000).SetSpendLimit)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/v1/account/spend-limit", strings.NewReader(`{"self_limit_cents":1000}`))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status: want 401 got %d", w.Code)
|
||||
}
|
||||
assertErrCode(t, w, "UNAUTHORIZED")
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
func addressLog() *slog.Logger { return slog.With("component", "address") }
|
||||
|
||||
type AddressHandler struct {
|
||||
shippoKey string
|
||||
bringAPIUID string
|
||||
@@ -33,20 +35,20 @@ func NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey string) *AddressHa
|
||||
|
||||
// Retrieve all saved addresses for a user
|
||||
func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var addresses []models.Address
|
||||
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"addresses": addresses,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
@@ -76,11 +78,11 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
switch req.Country {
|
||||
case "US":
|
||||
if h.shippoKey == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "CONFIG_ERROR", "shippo not configured for US address validation", nil)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CONFIG_ERROR", "shippo not configured for US address validation", nil)
|
||||
return
|
||||
}
|
||||
client := shippo.NewClient(h.shippoKey)
|
||||
validated, err := client.ValidateAddress(shippo.Address{
|
||||
validated, err := client.ValidateAddress(r.Context(), shippo.Address{
|
||||
Name: req.Name,
|
||||
Street1: req.Street,
|
||||
Street2: req.Street2,
|
||||
@@ -91,7 +93,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
Phone: req.Phone,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("shippo address validation failed: %v", err)
|
||||
addressLog().Warn("shippo address validation failed", "error", err, "country", "US")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
|
||||
return
|
||||
}
|
||||
@@ -112,7 +114,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
client := bring.NewClient(h.bringAPIUID, h.bringAPIKey)
|
||||
validated, err := client.ValidateAddress(bring.Address{
|
||||
validated, err := client.ValidateAddress(r.Context(), bring.Address{
|
||||
Name: req.Name,
|
||||
Street1: req.Street,
|
||||
Street2: req.Street2,
|
||||
@@ -122,7 +124,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
Phone: req.Phone,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("bring address validation failed: %v", err)
|
||||
addressLog().Warn("bring address validation failed", "error", err, "country", "NO")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
|
||||
return
|
||||
}
|
||||
@@ -141,19 +143,25 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// remove existing addres with the same street addres
|
||||
var existing models.Address
|
||||
if err := db.Where("user_id = ? AND street = ?", userID, address.Street).First(&existing).Error; err == nil {
|
||||
db.Delete(&existing)
|
||||
}
|
||||
|
||||
if err := db.Create(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAddress deltes a saved address
|
||||
func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -166,14 +174,16 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
var address models.Address
|
||||
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ADDRESS NOT FOUND", "address not found", nil)
|
||||
utils.RespondError(w, http.StatusNotFound, "ADDRESS_NOT_FOUND", "address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// ownership is guaranteed by the WHERE clause above
|
||||
db.Delete(&address)
|
||||
if err := db.Delete(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "address successfully deleted",
|
||||
})
|
||||
}
|
||||
@@ -181,7 +191,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
// Mark an address as the users default address
|
||||
|
||||
func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -197,12 +207,18 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false)
|
||||
if err := db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
address.IsDefault = true
|
||||
db.Save(&address)
|
||||
if err := db.Save(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func setupAddressTestDB(t *testing.T) (string, models.User) {
|
||||
t.Helper()
|
||||
testDB := "test_addresses.db"
|
||||
os.Remove(testDB)
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
@@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) {
|
||||
|
||||
func TestGetAddressesEmpty(t *testing.T) {
|
||||
testDB, user := setupAddressTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewAddressHandler("", "", "")
|
||||
@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Addresses []interface{} `json:"addresses"`
|
||||
Addresses []any `json:"addresses"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
@@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) {
|
||||
|
||||
func TestGetAddressesIsolation(t *testing.T) {
|
||||
testDB, user1 := setupAddressTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) {
|
||||
Addresses []models.Address `json:"addresses"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Addresses) != 1 {
|
||||
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses))
|
||||
@@ -117,7 +117,7 @@ func TestGetAddressesIsolation(t *testing.T) {
|
||||
|
||||
func TestGetAddressesWithData(t *testing.T) {
|
||||
testDB, user := setupAddressTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -158,4 +158,3 @@ func TestGetAddressesWithData(t *testing.T) {
|
||||
t.Errorf("expected 2 addresses, got: %d", len(resp.Data.Addresses))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
97
api/handlers/admin_users.go
Normal file
97
api/handlers/admin_users.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
// AdminUserHandler serves admin-gated user-administration endpoints. Routes that
|
||||
// use it are mounted behind middleware.RequireAdmin (X-Admin-Key), not the JWT
|
||||
// auth used by customer-facing endpoints.
|
||||
type AdminUserHandler struct{}
|
||||
|
||||
// NewAdminUserHandler constructs an AdminUserHandler.
|
||||
func NewAdminUserHandler() *AdminUserHandler {
|
||||
return &AdminUserHandler{}
|
||||
}
|
||||
|
||||
// SetUserOrderCap sets or clears a user's per-user spend-cap override
|
||||
// (User.MaxOrderCents). Body: {"max_order_cents": <int|null>}.
|
||||
// - null -> clears the override; the user reverts to the global MAX_ORDER_CENTS
|
||||
// - 0 -> per-user off-switch (disables the cap for this user)
|
||||
// - >0 -> custom ceiling
|
||||
//
|
||||
// Negative values are rejected here at the write boundary (HTTP 400) to keep bad
|
||||
// data out of the column. The read path in cart.go:ConvertCart independently
|
||||
// defends against a negative that arrives via a direct DB write (falls back to
|
||||
// the global cap), so the two layers cover each other.
|
||||
//
|
||||
// Adapted from the admin OrderHandler.UpdateTracking pattern in
|
||||
// api/handlers/orders.go (URL id param -> body decode -> row lookup -> update ->
|
||||
// audit -> respond).
|
||||
func (h *AdminUserHandler) SetUserOrderCap(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid user id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode raw so an explicit null (clear) is distinguishable from an absent key.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
rawCap, ok := raw["max_order_cents"]
|
||||
if !ok {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD",
|
||||
"max_order_cents is required (use null to clear the override)", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var capCents *int
|
||||
if string(rawCap) != "null" {
|
||||
var n int
|
||||
if err := json.Unmarshal(rawCap, &n); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY",
|
||||
"max_order_cents must be an integer or null", nil)
|
||||
return
|
||||
}
|
||||
if n < 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_CAP",
|
||||
"max_order_cents must be >= 0 (use null to clear the override)", nil)
|
||||
return
|
||||
}
|
||||
capCents = &n
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Map form so a nil pointer persists SQL NULL; a struct Updates would skip it.
|
||||
if err := db.Model(&user).Updates(map[string]any{"max_order_cents": capCents}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update order cap", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.UserOrderCapSet("admin", user.ID, capCents)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user_id": user.ID,
|
||||
"max_order_cents": capCents,
|
||||
})
|
||||
}
|
||||
194
api/handlers/admin_users_test.go
Normal file
194
api/handlers/admin_users_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// adminCapRequest builds a PATCH request carrying the {id} chi URL param, as the
|
||||
// admin order-cap route would. No JWT — the route is gated by RequireAdmin.
|
||||
func adminCapRequest(idParam, body string) *http.Request {
|
||||
req := httptest.NewRequest("PATCH", "/api/v1/users/"+idParam+"/order-cap", strings.NewReader(body))
|
||||
rc := chi.NewRouteContext()
|
||||
rc.URLParams.Add("id", idParam)
|
||||
return req.WithContext(contextWithRoute(req.Context(), rc))
|
||||
}
|
||||
|
||||
func eqIntPtr(a, b *int) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func fmtIntPtr(p *int) string {
|
||||
if p == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
func TestSetUserOrderCap(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seed *int // starting MaxOrderCents on the user row
|
||||
body string
|
||||
wantStatus int
|
||||
wantCode string // error code; "" means success
|
||||
wantCap *int // expected DB value after a successful update
|
||||
}{
|
||||
{"set positive", nil, `{"max_order_cents":7500}`, http.StatusOK, "", intPtr(7500)},
|
||||
{"set zero off-switch", nil, `{"max_order_cents":0}`, http.StatusOK, "", intPtr(0)},
|
||||
{"clear with null reverts to global", intPtr(500), `{"max_order_cents":null}`, http.StatusOK, "", nil},
|
||||
{"overwrite existing value", intPtr(500), `{"max_order_cents":12000}`, http.StatusOK, "", intPtr(12000)},
|
||||
{"negative rejected", nil, `{"max_order_cents":-1}`, http.StatusBadRequest, "INVALID_CAP", nil},
|
||||
{"missing field rejected", nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
|
||||
{"non-integer rejected", nil, `{"max_order_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
|
||||
{"malformed json rejected", nil, `{`, http.StatusBadRequest, "INVALID_BODY", nil},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
db := database.GetDB()
|
||||
if tc.seed != nil {
|
||||
if err := db.Model(&user).Update("max_order_cents", *tc.seed).Error; err != nil {
|
||||
t.Fatalf("seed cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewAdminUserHandler()
|
||||
req := adminCapRequest(strconv.FormatUint(uint64(user.ID), 10), tc.body)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetUserOrderCap(w, req)
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Error.Code != tc.wantCode {
|
||||
t.Errorf("error code: want %q got %q", tc.wantCode, resp.Error.Code)
|
||||
}
|
||||
|
||||
var got models.User
|
||||
if err := db.First(&got, user.ID).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if tc.wantStatus == http.StatusOK {
|
||||
if !eqIntPtr(got.MaxOrderCents, tc.wantCap) {
|
||||
t.Errorf("db MaxOrderCents: want %s got %s", fmtIntPtr(tc.wantCap), fmtIntPtr(got.MaxOrderCents))
|
||||
}
|
||||
} else if !eqIntPtr(got.MaxOrderCents, tc.seed) {
|
||||
// A rejected request must not mutate the column.
|
||||
t.Errorf("rejected request changed db cap: want %s got %s", fmtIntPtr(tc.seed), fmtIntPtr(got.MaxOrderCents))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserOrderCap_InvalidID(t *testing.T) {
|
||||
setupOrderTestDB(t)
|
||||
w := httptest.NewRecorder()
|
||||
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("abc", `{"max_order_cents":100}`))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: want 400 got %d", w.Code)
|
||||
}
|
||||
assertErrCode(t, w, "INVALID_ID")
|
||||
}
|
||||
|
||||
func TestSetUserOrderCap_UserNotFound(t *testing.T) {
|
||||
setupOrderTestDB(t)
|
||||
w := httptest.NewRecorder()
|
||||
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("99999", `{"max_order_cents":100}`))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: want 404 got %d", w.Code)
|
||||
}
|
||||
assertErrCode(t, w, "USER_NOT_FOUND")
|
||||
}
|
||||
|
||||
// TestSetUserOrderCap_AdminGate exercises the RequireAdmin middleware through a
|
||||
// real router (the handler-direct tests above bypass middleware).
|
||||
func TestSetUserOrderCap_AdminGate(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
idStr := strconv.FormatUint(uint64(user.ID), 10)
|
||||
|
||||
router := func() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.With(middleware.RequireAdmin).
|
||||
Patch("/api/v1/users/{id}/order-cap", NewAdminUserHandler().SetUserOrderCap)
|
||||
return r
|
||||
}
|
||||
newReq := func() *http.Request {
|
||||
return httptest.NewRequest("PATCH", "/api/v1/users/"+idStr+"/order-cap",
|
||||
strings.NewReader(`{"max_order_cents":100}`))
|
||||
}
|
||||
|
||||
t.Run("disabled when ADMIN_API_KEY unset", func(t *testing.T) {
|
||||
t.Setenv("ADMIN_API_KEY", "")
|
||||
w := httptest.NewRecorder()
|
||||
router().ServeHTTP(w, newReq())
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status: want 503 got %d", w.Code)
|
||||
}
|
||||
assertErrCode(t, w, "ADMIN_DISABLED")
|
||||
})
|
||||
|
||||
t.Run("rejects wrong key", func(t *testing.T) {
|
||||
t.Setenv("ADMIN_API_KEY", "secret")
|
||||
req := newReq()
|
||||
req.Header.Set("X-Admin-Key", "nope")
|
||||
w := httptest.NewRecorder()
|
||||
router().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status: want 401 got %d", w.Code)
|
||||
}
|
||||
assertErrCode(t, w, "UNAUTHORIZED")
|
||||
})
|
||||
|
||||
t.Run("accepts correct key", func(t *testing.T) {
|
||||
t.Setenv("ADMIN_API_KEY", "secret")
|
||||
req := newReq()
|
||||
req.Header.Set("X-Admin-Key", "secret")
|
||||
w := httptest.NewRecorder()
|
||||
router().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200 got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var got models.User
|
||||
if err := database.GetDB().First(&got, user.ID).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if !eqIntPtr(got.MaxOrderCents, intPtr(100)) {
|
||||
t.Errorf("db MaxOrderCents: want 100 got %s", fmtIntPtr(got.MaxOrderCents))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertErrCode(t *testing.T, w *httptest.ResponseRecorder, want string) {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Error.Code != want {
|
||||
t.Errorf("error code: want %q got %q", want, resp.Error.Code)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
@@ -12,6 +14,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func authLog() *slog.Logger { return slog.With("component", "auth") }
|
||||
|
||||
// AuthHandler handles authentication-related requests
|
||||
type AuthHandler struct {
|
||||
jwtManager *auth.JWTManager
|
||||
@@ -42,22 +46,26 @@ type TokenResponse struct {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
var req TokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
middleware.RecordAuthAttempt("invalid_json")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// validate shared secret
|
||||
if req.ClientSecret != h.authFingerprintKey {
|
||||
middleware.RecordAuthAttempt("invalid_secret")
|
||||
authLog().Warn("invalid client secret", "remote", r.RemoteAddr)
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SECRET", "invalid client secret", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Return if no fingerprint is found
|
||||
if req.Fingerprint == "" {
|
||||
middleware.RecordAuthAttempt("missing_fingerprint")
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint is required", nil)
|
||||
return
|
||||
}
|
||||
@@ -67,33 +75,44 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
|
||||
err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if req.SSHPublicKey == "" {
|
||||
middleware.RecordAuthAttempt("user_not_found")
|
||||
authLog().Warn("user not found and no public key for registration", "fingerprint", req.Fingerprint)
|
||||
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil)
|
||||
return
|
||||
}
|
||||
user = models.User{
|
||||
SSHKeyFingerprint: req.Fingerprint,
|
||||
SSHPublicKey: req.SSHPublicKey,
|
||||
SSHPublicKey: req.SSHPublicKey,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
middleware.RecordAuthAttempt("error")
|
||||
authLog().Error("failed to create user", "error", err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil)
|
||||
return
|
||||
}
|
||||
middleware.RecordAuthAttempt("registered")
|
||||
authLog().Info("user registered", "user_id", user.ID)
|
||||
} else if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "database error", nil)
|
||||
middleware.RecordAuthAttempt("error")
|
||||
authLog().Error("failed to look up user", "error", err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to look up user", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
middleware.RecordAuthAttempt("error")
|
||||
authLog().Error("failed to generate token", "user_id", user.ID, "error", err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
middleware.RecordAuthAttempt("success")
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800, // 30 minutes in seconds
|
||||
"user": user.ToPublic(),
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,7 +127,7 @@ type RegisterRequest struct {
|
||||
// RegisterWithSSHKey registers a new user with their SSH public key
|
||||
// No username required - following terminal.shop pattern
|
||||
func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -127,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{
|
||||
"errors": errors,
|
||||
})
|
||||
return
|
||||
@@ -154,14 +173,14 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserBySSHKey retrieves a user by their SSH key fingerprint
|
||||
func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
fingerprint := r.URL.Query().Get("fingerprint")
|
||||
if fingerprint == "" {
|
||||
@@ -176,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret"
|
||||
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
|
||||
t.Helper()
|
||||
testDB := "test_auth.db"
|
||||
os.Remove(testDB)
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
@@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User {
|
||||
|
||||
func TestGetTokenSuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
user := createAuthTestUser(t, "SHA256:testfingerprint123")
|
||||
@@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) {
|
||||
|
||||
func TestGetTokenBadSecret(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) {
|
||||
|
||||
func TestGetTokenMissingFingerprint(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) {
|
||||
|
||||
func TestGetTokenUnknownFingerprint(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) {
|
||||
|
||||
func TestGetTokenInvalidJSON(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json"))
|
||||
@@ -185,7 +185,7 @@ func TestGetTokenInvalidJSON(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKeySuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
createAuthTestUser(t, "SHA256:existingfp")
|
||||
@@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
|
||||
|
||||
func TestGetUserBySSHKeySuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
createAuthTestUser(t, "SHA256:lookupfp")
|
||||
@@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) {
|
||||
|
||||
func TestGetUserBySSHKeyNotFound(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil)
|
||||
@@ -282,7 +282,7 @@ func TestGetUserBySSHKeyNotFound(t *testing.T) {
|
||||
|
||||
func TestGetUserBySSHKeyMissingParam(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)
|
||||
|
||||
156
api/handlers/card_test.go
Normal file
156
api/handlers/card_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// TestGetCards_FiltersExpiredRows drives the SQL WHERE claude used by
|
||||
// GetCards. a column-name typo (e.g used_id instead of user_id) surfaces
|
||||
// here as a sqlite error, not in prod
|
||||
func TestGetCards_FiltersExpiredRows(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
now := time.Now()
|
||||
past := now.Add(-1 * time.Hour)
|
||||
future := now.Add(24 * time.Hour)
|
||||
|
||||
expired := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
||||
}
|
||||
fresh := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_fresh", Last4: "2222",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
||||
}
|
||||
legacy := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "3333",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
|
||||
}
|
||||
for _, card := range []*models.Card{&expired, &fresh, &legacy} {
|
||||
if err := db.Create(card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewCardHandler("")
|
||||
req := authRequest("GET", "/api/v1/cards", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetCards(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetCards status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cards []models.Card `json:"cards"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data.Cards) != 2 {
|
||||
t.Fatalf("want 2 visible cards (fresh + legacy NULL), got %d", len(resp.Data.Cards))
|
||||
}
|
||||
for _, card := range resp.Data.Cards {
|
||||
if card.ID == expired.ID {
|
||||
t.Fatalf("expired card leaked into GetCards response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDefaultCard_ExpiredReturns410 verifies the sync-expire branch when
|
||||
// the user acts on a card whose retention deadline already elapsed
|
||||
func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
card := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCardHandler("")
|
||||
idStr := strconv.FormatUint(uint64(card.ID), 10)
|
||||
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", idStr)
|
||||
req = req.WithContext(contextWithRoute(req.Context(), routeCtx))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetDefaultCard(w, req)
|
||||
|
||||
if w.Code != http.StatusGone {
|
||||
t.Fatalf("want 410 gone, got %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
|
||||
t.Fatalf("want CARD_STORAGE_EXPIRED code, got %q", resp.Error.Code)
|
||||
}
|
||||
|
||||
var soft models.Card
|
||||
if err := db.Unscoped().First(&soft, card.ID).Error; err != nil {
|
||||
t.Fatalf("reload card unscoped: %v", err)
|
||||
}
|
||||
if !soft.DeletedAt.Valid {
|
||||
t.Fatalf("expired card was not soft-deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshCardStorageTTL_BumpLastUsedAndExpiry exercises the SQL Updates
|
||||
// map used by both the payment_intent.succeede webhook and the duplicate-card
|
||||
// add path. Column-name typos in the map surface here
|
||||
func TestRefreshCardStorageTTL_BumpLastUsedAndExpiry(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
old := time.Now().Add(-30 * 24 * time.Hour)
|
||||
card := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_refresh", Last4: "4242",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &old,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := refreshCardStorageTTL(db, card.ID, now); err != nil {
|
||||
t.Fatalf("refreshCardStorageTTL: %v", err)
|
||||
}
|
||||
|
||||
var reloaded models.Card
|
||||
if err := db.First(&reloaded, card.ID).Error; err != nil {
|
||||
t.Fatalf("reload card: %v", err)
|
||||
}
|
||||
if reloaded.LastUsedAt == nil || !reloaded.StorageExpiresAt.After(now.Add(179*24*time.Hour)) {
|
||||
t.Fatalf("storage_expires_at not extended, got %v", reloaded.StorageExpiresAt)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
@@ -24,64 +26,79 @@ import (
|
||||
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
|
||||
)
|
||||
|
||||
// payRedirects maps short tokens to full Stripe checkout URLs.
|
||||
var (
|
||||
payRedirects = map[string]string{}
|
||||
payRedirectsMu sync.Mutex
|
||||
)
|
||||
const RedirectTTL = 10 * time.Minute
|
||||
|
||||
func storeRedirect(stripeURL string) string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
func storeRedirect(stripeURL, purpose string) (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
payRedirectsMu.Lock()
|
||||
payRedirects[token] = stripeURL
|
||||
payRedirectsMu.Unlock()
|
||||
return token
|
||||
now := time.Now()
|
||||
rec := models.PayRedirect{
|
||||
Token: token,
|
||||
URL: stripeURL,
|
||||
Purpose: purpose,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(RedirectTTL),
|
||||
}
|
||||
if err := database.GetDB().Create(&rec).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
|
||||
func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
payRedirectsMu.Lock()
|
||||
url, ok := payRedirects[token]
|
||||
payRedirectsMu.Unlock()
|
||||
if !ok {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
var rec models.PayRedirect
|
||||
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
if time.Now().After(rec.ExpiresAt) {
|
||||
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, rec.URL, http.StatusFound)
|
||||
}
|
||||
|
||||
// CardHandler handles saved payment method CRUD.
|
||||
type CardHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
appURL string
|
||||
}
|
||||
|
||||
// NewCardHandler creates a new card handler with the Stripe secret key.
|
||||
func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
|
||||
return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||
// NewCardHandler creates a new card handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewCardHandler(appURL string) *CardHandler {
|
||||
return &CardHandler{appURL: appURL}
|
||||
}
|
||||
|
||||
// GetCards returns all saved cards for the authenticated user.
|
||||
// Cards past their retention deadline are filtered out at read time;
|
||||
// physical deletion runs in handlers.ReconcileExpiredCards.
|
||||
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var cards []models.Card
|
||||
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||
db.Where(
|
||||
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
|
||||
userID, time.Now(),
|
||||
).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cards": cards,
|
||||
})
|
||||
}
|
||||
|
||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
|
||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any)
|
||||
|
||||
// GetCard returns a single saved card by ID.
|
||||
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -97,13 +114,13 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -119,18 +136,33 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false)
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
card.IsDefault = true
|
||||
db.Save(&card)
|
||||
if err := db.Save(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var user models.User
|
||||
@@ -139,8 +171,6 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
@@ -160,8 +190,12 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token := storeRedirect(sess.URL)
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||
return
|
||||
}
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||
})
|
||||
}
|
||||
@@ -178,7 +212,7 @@ type SaveCardRequest struct {
|
||||
// fingerprint, attaches to the user's Stripe customer, and saves the
|
||||
// card metadata locally. No raw card data is stored at any point.
|
||||
func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SaveCardRequest
|
||||
@@ -198,8 +232,6 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
// Ensure the user has a Stripe customer, creating one if needed.
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
@@ -242,6 +274,13 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// remove existing local card with the same last4 + brand + expiry
|
||||
var existingCard models.Card
|
||||
if err := db.Where("user_id = ? AND last4 = ? AND brand = ? AND exp_month = ? AND exp_year = ?",
|
||||
user.ID, pm.Card.Last4, string(pm.Card.Brand), pm.Card.ExpMonth, pm.Card.ExpYear).First(&existingCard).Error; err == nil {
|
||||
db.Delete(&existingCard)
|
||||
}
|
||||
|
||||
// Persist only the safe card metadata — no raw card data is stored.
|
||||
card := models.Card{
|
||||
UserID: user.ID,
|
||||
@@ -259,7 +298,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -267,7 +306,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
// DeleteCard removes a saved card. If the card is currently set on the user's
|
||||
// cart, the cart's card selection is cleared.
|
||||
func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -285,24 +324,75 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Clear the card from any cart that references it.
|
||||
cardID := uint(id)
|
||||
db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil)
|
||||
if err := db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to clear card from cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Detach from Stripe (best-effort; don't block deletion on Stripe errors).
|
||||
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
|
||||
stripe.Key = h.stripeKey
|
||||
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
|
||||
}
|
||||
|
||||
// Soft-delete the local record.
|
||||
db.Delete(&card)
|
||||
if err := db.Delete(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.CardDeleted(userID, card.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "card deleted",
|
||||
})
|
||||
}
|
||||
|
||||
// expireStoredCard removes a card whose retention deadline elapsed.
|
||||
// Local state (cart references + card row) is committed atomically;
|
||||
// Stripe detach and audit run only after the local commit so retries
|
||||
// cannot double-fire the audit event. Stripe detach is gated on
|
||||
// stripe.Key being non-empty so tests (which never init the SDK key)
|
||||
// skip the real network call without an explicit flag.
|
||||
func expireStoredCard(db *gorm.DB, card *models.Card) error {
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil {
|
||||
return fmt.Errorf("failed to clear expired card from cart: %w", err)
|
||||
}
|
||||
if err := tx.Delete(card).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete expired card: %w", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if stripe.Key != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
|
||||
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
|
||||
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
audit.CardExpired(card.UserID, card.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func refreshCardStorageTTL(db *gorm.DB, cardID uint, now time.Time) error {
|
||||
return db.Model(&models.Card{}).Where("id = ?", cardID).Updates(map[string]any{
|
||||
"last_used_at": now,
|
||||
"storage_expires_at": models.CardStorageExpiresAt(now),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func respondCardStorageExpired(w http.ResponseWriter) {
|
||||
utils.RespondError(
|
||||
w,
|
||||
http.StatusGone,
|
||||
"CARD_STORAGE_EXPIRED",
|
||||
"saved card expired, add it again",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// ensureStripeCustomer creates a Stripe Customer for the user if they don't
|
||||
// already have one and persists the customer ID. Safe to call concurrently —
|
||||
// a duplicate Stripe customer is harmless; the local record reflects the first
|
||||
@@ -325,6 +415,8 @@ func ensureStripeCustomer(gdb *gorm.DB, user *models.User) error {
|
||||
return err
|
||||
}
|
||||
user.StripeCustomerID = cust.ID
|
||||
gdb.Save(user)
|
||||
if err := gdb.Save(user).Error; err != nil {
|
||||
return fmt.Errorf("failed to save stripe customer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
156
api/handlers/cards_expire_test.go
Normal file
156
api/handlers/cards_expire_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// TestReconcileExpiredCards_RemovesPastDeadlineOnly drives the real SQL path
|
||||
// so a column-name typo in the WHERE clause surfaces here, not in production.
|
||||
func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
now := time.Now()
|
||||
past := now.Add(-1 * time.Hour)
|
||||
future := now.Add(24 * time.Hour)
|
||||
|
||||
expired := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: "tok_expired",
|
||||
Last4: "1111",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2030,
|
||||
StorageExpiresAt: &past,
|
||||
}
|
||||
fresh := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: "tok_fresh",
|
||||
Last4: "2222",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2030,
|
||||
StorageExpiresAt: &future,
|
||||
}
|
||||
noTTL := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: "tok_legacy",
|
||||
Last4: "3333",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2030,
|
||||
}
|
||||
for _, c := range []*models.Card{&expired, &fresh, &noTTL} {
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cart := models.Cart{UserID: user.ID, CardID: &expired.ID}
|
||||
if err := db.Create(&cart).Error; err != nil {
|
||||
t.Fatalf("seed cart: %v", err)
|
||||
}
|
||||
|
||||
ReconcileExpiredCards(context.Background())
|
||||
|
||||
var remaining []models.Card
|
||||
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("list cards: %v", err)
|
||||
}
|
||||
if len(remaining) != 2 {
|
||||
t.Fatalf("want 2 cards remaining, got %d", len(remaining))
|
||||
}
|
||||
for _, c := range remaining {
|
||||
if c.ID == expired.ID {
|
||||
t.Fatalf("expired card was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
var refreshed models.Cart
|
||||
if err := db.First(&refreshed, cart.ID).Error; err != nil {
|
||||
t.Fatalf("reload cart: %v", err)
|
||||
}
|
||||
if refreshed.CardID != nil {
|
||||
t.Fatalf("cart still references expired card: %v", refreshed.CardID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileExpiredCards_SweepsAcrossUsers verifies the sweeper is not
|
||||
// scoped to one user; it must collect every past-deadline row in one pass.
|
||||
func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
other := models.User{
|
||||
SSHKeyFingerprint: "SHA256:other",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA other",
|
||||
Name: "Other",
|
||||
Email: "other@example.com",
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatalf("seed other user: %v", err)
|
||||
}
|
||||
|
||||
for _, uid := range []uint{user.ID, other.ID} {
|
||||
c := models.Card{
|
||||
UserID: uid,
|
||||
StripePaymentID: "tok_expired",
|
||||
Last4: "9999",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2030,
|
||||
StorageExpiresAt: &past,
|
||||
}
|
||||
if err := db.Create(&c).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ReconcileExpiredCards(context.Background())
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count cards: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("want 0 cards after sweep, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func setupCardsExpireTestDB(t *testing.T) (string, models.User) {
|
||||
t.Helper()
|
||||
testDB := "test_cards_expire.db"
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:cards-expire",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA cardsexpire",
|
||||
Name: "Test User",
|
||||
Email: "cards-expire@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return testDB, user
|
||||
}
|
||||
@@ -1,34 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
maxOrderCents int
|
||||
}
|
||||
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey}
|
||||
// maxAuthIssuances caps how many times a single ordes's PaymentIntent can be
|
||||
// pushed into requires_action; one initial challende plus up to three retries
|
||||
// before we tell the customer to use a different card. Counted via existing
|
||||
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
|
||||
const maxAuthIssuances = 4
|
||||
|
||||
// paymentIntentNewFn lets tests stub the Stripe create call without spinning
|
||||
// up an httptest backend. Production callers go straight through to the real
|
||||
// paymentintent.New. Mirrors paymentIntentGetFn in reconcile.go.
|
||||
var paymentIntentNewFn = paymentintent.New
|
||||
|
||||
var (
|
||||
paymentMethodNewFn = paymentmethod.New
|
||||
paymentMethodDetachFn = paymentmethod.Detach
|
||||
)
|
||||
|
||||
// NewCartHandler creates a new cart handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler {
|
||||
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
|
||||
}
|
||||
|
||||
// getOrCreateCart finds the users cart or creates one.
|
||||
func getOrCreateCart(userID uint) (*models.Cart, error) {
|
||||
db := database.GetDB()
|
||||
func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
var cart models.Cart
|
||||
err := db.Where("user_id = ?", userID).First(&cart).Error
|
||||
if err != nil {
|
||||
@@ -40,18 +67,18 @@ func getOrCreateCart(userID uint) (*models.Cart, error) {
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func cartResponse(cart *models.Cart) map[string]interface{} {
|
||||
db := database.GetDB()
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var items []models.CartItem
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
|
||||
subtotal := 0
|
||||
itemsData := make([]map[string]interface{}, 0, len(items))
|
||||
itemsData := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
lineTotal := item.Coffee.Price * item.Quantity
|
||||
subtotal += lineTotal
|
||||
itemsData = append(itemsData, map[string]interface{}{
|
||||
itemsData = append(itemsData, map[string]any{
|
||||
"id": item.ID,
|
||||
"coffee_id": item.CoffeeID,
|
||||
"quantity": item.Quantity,
|
||||
@@ -60,7 +87,7 @@ func cartResponse(cart *models.Cart) map[string]interface{} {
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"items": itemsData,
|
||||
"subtotal": subtotal,
|
||||
"address_id": cart.AddressID,
|
||||
@@ -73,14 +100,14 @@ func cartResponse(cart *models.Cart) map[string]interface{} {
|
||||
func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cart": cartResponse(cart),
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,7 +117,7 @@ type SetItemRequest struct {
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetItemRequest
|
||||
@@ -110,14 +137,17 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Quantity <= 0 {
|
||||
db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{})
|
||||
if err := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var item models.CartItem
|
||||
result := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).First(&item)
|
||||
@@ -127,14 +157,20 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
CoffeeID: req.CoffeeID,
|
||||
Quantity: req.Quantity,
|
||||
}
|
||||
db.Create(&item)
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create item", nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
db.Model(&item).Update("quantity", req.Quantity)
|
||||
if err := db.Model(&item).Update("quantity", req.Quantity).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update quantity", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cart": cartResponse(cart),
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,7 +179,7 @@ type SetAddressRequest struct {
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetAddressRequest
|
||||
@@ -158,15 +194,18 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(cart).Update("address_id", req.AddressID)
|
||||
if err := db.Model(cart).Update("address_id", req.AddressID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "address set",
|
||||
})
|
||||
}
|
||||
@@ -176,7 +215,7 @@ type SetCardRequest struct {
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetCardRequest
|
||||
@@ -191,42 +230,78 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(cart).Update("card_id", req.CardID)
|
||||
if err := db.Model(cart).Update("card_id", req.CardID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "card set",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
|
||||
if err := db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "cart cleared",
|
||||
})
|
||||
}
|
||||
|
||||
// ConvertCartRequest carries the optional privacy-checkout controls. The body
|
||||
// is optional: a saved.card checkout sends nothing and both field stay zero.
|
||||
// Ephemeral opts a single order into privacy mode (ont-time card, no Card row,
|
||||
// PM detached after settle); CardToken supplies that one-time card. A user with
|
||||
// PrivacyMode set is ephemeral regardless of the flag.
|
||||
type ConvertCartRequest struct {
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
CardToken string `json:"card_token"`
|
||||
}
|
||||
|
||||
func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
ctx, span := otel.Tracer("api").Start(r.Context(), "cart.convert")
|
||||
defer span.End()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
var req ConvertCartRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
middleware.RecordCartConversion("cart_lookup_failed")
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
@@ -235,69 +310,115 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
|
||||
if len(items) == 0 {
|
||||
middleware.RecordCartConversion("validation_empty_cart")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if cart.AddressID == nil {
|
||||
middleware.RecordCartConversion("validation_missing_address")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "shipping address is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if cart.CardID == nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var address models.Address
|
||||
if err := db.First(&address, *cart.AddressID).Error; err != nil {
|
||||
middleware.RecordCartConversion("validation_invalid_address")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "shipping address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var card models.Card
|
||||
if err := db.First(&card, *cart.CardID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
middleware.RecordCartConversion("user_not_found")
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
// Per-order intent drives the charge SHAPE: req.Ephemeral picks the one-time
|
||||
// token path (no saved card). Privacy mode adds a second guarantee on the
|
||||
// saved-card path — the live QR add-card flow has no "save anyway" prompt, so
|
||||
// a privacy user who selects a (QR-collected) card still must not have it
|
||||
// retained. forgetCard charges that card normally, then detaches the
|
||||
// PaymentMethod and hard-deletes the local Card row once the order settles.
|
||||
// It never applies to the token path, which has no local row.
|
||||
ephemeral := req.Ephemeral
|
||||
forgetCard := user.PrivacyMode && !ephemeral
|
||||
|
||||
// Ensure the user has a Stripe customer (shared helper with cards handler).
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// All cards are now saved as pm_ PaymentMethods. This branch handles any
|
||||
// legacy tok_ records that predate the server-side tokenization refactor.
|
||||
paymentMethodID := card.StripePaymentID
|
||||
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
|
||||
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
|
||||
Type: stripe.String("card"),
|
||||
Card: &stripe.PaymentMethodCardParams{
|
||||
Token: stripe.String(paymentMethodID),
|
||||
},
|
||||
})
|
||||
if pmErr != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
|
||||
var card models.Card
|
||||
var paymentMethodID string
|
||||
if ephemeral {
|
||||
if req.CardToken == "" {
|
||||
middleware.RecordCartConversion("validation_missing_card")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "card token is required for private checkout", nil)
|
||||
return
|
||||
}
|
||||
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
}); attachErr != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
|
||||
pm, pmerr := paymentMethodNewFn(&stripe.PaymentMethodParams{
|
||||
Type: stripe.String("card"),
|
||||
Card: &stripe.PaymentMethodCardParams{
|
||||
Token: stripe.String(req.CardToken),
|
||||
},
|
||||
})
|
||||
if pmerr != nil {
|
||||
middleware.RecordCartConversion("stripe_error")
|
||||
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "failed to create payment method", nil)
|
||||
return
|
||||
}
|
||||
paymentMethodID = pm.ID
|
||||
card.StripePaymentID = pm.ID
|
||||
db.Save(&card)
|
||||
} else {
|
||||
if cart.CardID == nil {
|
||||
middleware.RecordCartConversion("validation_missing_card")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.First(&card, *cart.CardID).Error; err != nil {
|
||||
middleware.RecordCartConversion("validation_invalid_card")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
middleware.RecordCartConversion("validation_card_expired")
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
middleware.RecordCartConversion("stripe_customer_failed")
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
}
|
||||
|
||||
paymentMethodID = card.StripePaymentID
|
||||
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
|
||||
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
|
||||
Type: stripe.String("card"),
|
||||
Card: &stripe.PaymentMethodCardParams{
|
||||
Token: stripe.String(paymentMethodID),
|
||||
},
|
||||
})
|
||||
if pmErr != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
|
||||
return
|
||||
}
|
||||
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
}); attachErr != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
|
||||
return
|
||||
}
|
||||
paymentMethodID = pm.ID
|
||||
card.StripePaymentID = pm.ID
|
||||
if err := db.Save(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var subtotal int
|
||||
@@ -315,7 +436,52 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
total := subtotal + cart.ShippingCost
|
||||
|
||||
// Per-user overridable spend cap. The override may raise OR lower the global
|
||||
// cap — operators set it in both directions deliberately. A negative override
|
||||
// is nonsense: ignore it and fall back to the global so an erroneous DB write
|
||||
// cannot silently disable a fraud control (mirrors loadMaxOrderCents in
|
||||
// pkg/config/config.go, which defaults a negative MAX_ORDER_CENTS). An explicit
|
||||
// 0 is a valid per-user off-switch (same semantics as the global).
|
||||
capCents := h.maxOrderCents
|
||||
if user.MaxOrderCents != nil {
|
||||
if *user.MaxOrderCents < 0 {
|
||||
slog.Warn("ignoring negative per-user order cap, using global",
|
||||
"user_id", user.ID,
|
||||
"user_cap_cents", *user.MaxOrderCents,
|
||||
"global_cap_cents", h.maxOrderCents)
|
||||
} else {
|
||||
capCents = *user.MaxOrderCents
|
||||
}
|
||||
}
|
||||
|
||||
// A user-set self-limit may only TIGHTEN the effective cap, never raise it.
|
||||
// a compromised account must not be able to lift its own ceiling to drain a
|
||||
// stolen card. Unlike the admin/global cap (where 0 is an off-switch), a
|
||||
// present self-limit always activates a ceiling, a self-limit of 0 means
|
||||
// "block everything", not "disable", so it folds in via min() and can only
|
||||
// lower capCents. A negative value is ignored (mirrors the maxOrderCents
|
||||
// guard above); this min() is the authoritative lower-only guard.
|
||||
capActive := capCents > 0
|
||||
if user.SelfLimitCents != nil && *user.SelfLimitCents >= 0 {
|
||||
if self := *user.SelfLimitCents; !capActive || self < capCents {
|
||||
capCents = self
|
||||
}
|
||||
capActive = true
|
||||
}
|
||||
if capActive && total > capCents {
|
||||
middleware.RecordCartConversion("validation_over_limit")
|
||||
audit.CartRejected(userID, total, capCents)
|
||||
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
|
||||
fmt.Sprintf("order total must be at most $%.2f", float64(capCents)/100),
|
||||
map[string]any{
|
||||
"total_cents": total,
|
||||
"limit_cents": capCents,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if total < 50 {
|
||||
middleware.RecordCartConversion("validation_min_amount")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
||||
return
|
||||
}
|
||||
@@ -326,6 +492,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: user.ID,
|
||||
CardID: card.ID,
|
||||
StripePaymentID: "",
|
||||
Ephemeral: ephemeral || forgetCard,
|
||||
Status: models.OrderStatusPending,
|
||||
Subtotal: subtotal,
|
||||
ShippingCost: cart.ShippingCost,
|
||||
@@ -342,25 +509,34 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
middleware.RecordCartConversion("db_create_order_failed")
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.OrderCreated(userID, order.ID, total)
|
||||
|
||||
// Charge the card. The idempotency key is tied to the order ID so retries
|
||||
// never produce a double charge.
|
||||
// Charge the card. This is a Customer-Initiated Transaction (CIT): the
|
||||
// customer is present in the TUI at checkout. We deliberately do NOT set
|
||||
// OffSession — that flag marks a Merchant-Initiated Transaction (MIT),
|
||||
// which under PSD2/SCA may only claim an exemption when a stored mandate
|
||||
// authorises the merchant to charge while the customer is away. We capture
|
||||
// no mandate and run no recurring billing, so MIT would misrepresent the
|
||||
// transaction type. As a CIT, an SCA challenge surfaces as
|
||||
// pi.Status == requires_action (handled below), not as an
|
||||
// authentication_required error. See docs/sca-psd2-compliance.md "Gap 1".
|
||||
// Reference: stripe-samples accept-a-payment custom-payment-flow —
|
||||
// php/public/google-pay.php drives 3DS off status==requires_action and sets
|
||||
// no off_session on a customer-present confirm.
|
||||
// The idempotency key is tied to the order ID so retries never double charge.
|
||||
piParams := &stripe.PaymentIntentParams{
|
||||
Amount: stripe.Int64(int64(total)),
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
||||
Enabled: stripe.Bool(true),
|
||||
AllowRedirects: stripe.String("never"),
|
||||
},
|
||||
Confirm: stripe.Bool(true),
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
||||
Amount: stripe.Int64(int64(total)),
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
Confirm: stripe.Bool(true),
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
||||
Shipping: &stripe.ShippingDetailsParams{
|
||||
Name: stripe.String(address.Name),
|
||||
Address: &stripe.AddressParams{
|
||||
@@ -378,27 +554,54 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
"order_id": fmt.Sprintf("%d", order.ID),
|
||||
}
|
||||
|
||||
pi, err := paymentintent.New(piParams)
|
||||
if !ephemeral {
|
||||
piParams.Customer = stripe.String(user.StripeCustomerID)
|
||||
}
|
||||
|
||||
stripeStart := time.Now()
|
||||
pi, err := paymentIntentNewFn(piParams)
|
||||
stripeDur := time.Since(stripeStart).Seconds()
|
||||
if err != nil {
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
||||
if stripeErr, ok := err.(*stripe.Error); ok {
|
||||
// Defensive fallback: as CIT (no off_session) Stripe normally
|
||||
// signals 3DS via status=requires_action below, not via this error.
|
||||
// authentication_required can still appear if the saved card needs
|
||||
// auth that could not run inline; respondRequiresAction re-Confirms
|
||||
// on-session to recover. (Pre-CIT migration this was the main path.)
|
||||
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
|
||||
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
switch stripeErr.Type {
|
||||
case stripe.ErrorTypeCard:
|
||||
middleware.RecordCartConversion("card_declined")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
||||
case stripe.ErrorTypeInvalidRequest:
|
||||
middleware.RecordCartConversion("stripe_invalid_request")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil)
|
||||
default:
|
||||
middleware.RecordCartConversion("stripe_error")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Error())
|
||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
middleware.RecordCartConversion("stripe_error")
|
||||
audit.OrderFailed(userID, order.ID, total, err.Error())
|
||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||
return
|
||||
}
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
||||
|
||||
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
|
||||
h.respondRequiresAction(w, &order, paymentMethodID, pi)
|
||||
return
|
||||
}
|
||||
|
||||
// Commit payment ID and clear cart in a single transaction.
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -410,7 +613,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(cart).Updates(map[string]interface{}{
|
||||
return tx.Model(cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -420,17 +623,285 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
// most dangerous failure mode: the customer paid but has no order.
|
||||
// The reconciliation job will catch this within 5 minutes.
|
||||
// The audit log entry below must be treated as a critical alert.
|
||||
middleware.RecordCartConversion("payment_critical")
|
||||
audit.PaymentCritical(order.ID, pi.ID, txErr)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order — please contact support", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if ephemeral || forgetCard {
|
||||
if _, derr := paymentMethodDetachFn(paymentMethodID, nil); derr != nil {
|
||||
slog.Warn("failed to detach ephemeral payment method", "order_id", order.ID, "error", derr)
|
||||
}
|
||||
}
|
||||
if forgetCard {
|
||||
// Privacy mode: the card was persisted only long enough to select and
|
||||
// charge it. Null the order's reference and hard-delete the local row so
|
||||
// no card data (last4/brand/token) survives the checkout. Unscoped because
|
||||
// Card uses gorm soft-delete; a plain delete would leave the data on disk.
|
||||
// Mirrors upstream Card.remove (terminal-shop-source core/src/card/index.ts):
|
||||
// detach the PM, drop the FK, delete the row.
|
||||
if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
|
||||
slog.Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
|
||||
}
|
||||
if err := db.Unscoped().Delete(&models.Card{}, card.ID).Error; err != nil {
|
||||
slog.Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", card.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
audit.OrderPaid(userID, order.ID, total, pi.ID)
|
||||
middleware.RecordOrderCreated(string(order.Status))
|
||||
middleware.RecordCartConversion("success")
|
||||
middleware.ObserveOrderValueCents(total)
|
||||
|
||||
db.Preload("Items").First(&order, order.ID)
|
||||
if err := db.Preload("Items").First(&order, order.ID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to preload items", nil)
|
||||
return
|
||||
}
|
||||
go notify.SlackOrderPaid(&order)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order": order,
|
||||
})
|
||||
}
|
||||
|
||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order
|
||||
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md);
|
||||
// the payment_intent.succeeded webhook flips it to paid once the customer
|
||||
// completes the bank-hosted challenge. The TUI polls /api/v1/orders/{id}/status.
|
||||
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
|
||||
db := database.GetDB()
|
||||
|
||||
// After an off-session authentication_required decline, the PI sits in
|
||||
// status=requires_payment_method with payment_method detached. Re-confirm
|
||||
// on-session with the same PM so Stripe pushes the PI into requires_action
|
||||
// and populates next_action.redirect_to_url with the 3DS challenge URL.
|
||||
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
OffSession: stripe.Bool(false),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
})
|
||||
if cerr == nil && confirmed != nil {
|
||||
pi = confirmed
|
||||
}
|
||||
}
|
||||
|
||||
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||
middleware.RecordCartConversion("sca_missing_next_action")
|
||||
db.Model(order).Update("status", models.OrderStatusFailed)
|
||||
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the paymentIntent id AND set status to requires_action so list
|
||||
// UIs can distinguish "charging" from "awaiting customer 3DS". The
|
||||
// payment_intent.payment_failed webhook from the initial off-session
|
||||
// decline often arrives before this response and flips the order to
|
||||
// failed - override it back so the 3DS-in-flight order shows the
|
||||
// correct state
|
||||
if err := db.Model(order).Updates(map[string]any{
|
||||
"stripe_payment_id": pi.ID,
|
||||
"status": models.OrderStatusRequiresAction,
|
||||
}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
middleware.RecordCartConversion("requires_action")
|
||||
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"payment_intent_id": pi.ID,
|
||||
"status": "requires_action",
|
||||
"redirect_token": token,
|
||||
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||
})
|
||||
}
|
||||
|
||||
func mapStripeCardErrCode(code stripe.ErrorCode) string {
|
||||
switch code {
|
||||
case stripe.ErrorCodeAuthenticationRequired:
|
||||
return "AUTHENTICATION_REQUIRED"
|
||||
case "authentication_not_supported":
|
||||
return "AUTHENTICATION_NOT_SUPPORTED"
|
||||
case "card_declined", "":
|
||||
return "CARD_DECLINED"
|
||||
case "expired_card":
|
||||
return "CARD_EXPIRED"
|
||||
case "incorrect_cvc":
|
||||
return "CARD_CVC_FAILED"
|
||||
case "insufficient_funds":
|
||||
return "CARD_INSUFFICIENT_FUNDS"
|
||||
case "processing_error":
|
||||
return "CARD_PROCESSING_ERROR"
|
||||
}
|
||||
return "CARD_DECLINED"
|
||||
}
|
||||
|
||||
//nolint:unused // wired into Convert cart later
|
||||
func appURLForHandler(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := r.Host
|
||||
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
||||
host = fh
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed
|
||||
// or abandoned during the 3DS challenge, recovering the existing order rather
|
||||
// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth.
|
||||
//
|
||||
// Stripe's PI state machine bounces back to requires_payment_method after a
|
||||
// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh
|
||||
// next_action.redirect_to_url — the same shape respondRequiresAction already
|
||||
// handles. No idempotency key is set: Confirm mutates an existing PI and
|
||||
// Stripe collapses by id (see sca-psd2-compliance.md caveat #5).
|
||||
func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id64, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
orderID := uint(id64)
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Retryable source states:
|
||||
// pending - initial attempt mid-flight (pre-PI confirm page)
|
||||
// requires_action - customer abandoned or failed the 3DS challenge
|
||||
// failed - webhook flipped after a non-recoverable error
|
||||
// terminal states (paid, shipped, delivered, cancelled, refunded) refuse.
|
||||
if order.Status != models.OrderStatusPending &&
|
||||
order.Status != models.OrderStatusRequiresAction &&
|
||||
order.Status != models.OrderStatusFailed {
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
|
||||
return
|
||||
}
|
||||
if order.StripePaymentID == "" {
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order has no payment intent to retry", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Cap is best-effort: two concurrent retries at issuances=cap-1 will both
|
||||
// pass this check and both Confirm before either writes a new event row.
|
||||
// Per-user RateLimitByUser(10/min) on the route bounds the worst case.
|
||||
// SQLite-WAL has no SELECT ... FOR UPDATE, so a stricter guard would need
|
||||
// a serializing INSERT - not worth the cimplexity at current traffic.
|
||||
var issuances int64
|
||||
if err := db.Model(&models.OrderEvent{}).
|
||||
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
|
||||
Count(&issuances).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to count auth attempts", nil)
|
||||
return
|
||||
}
|
||||
if issuances >= maxAuthIssuances {
|
||||
middleware.RecordCartConversion("retry_max_attempts")
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RETRY_LIMIT", "too many authentication attempts, please use a different card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// scoped card lookup - match the initial-flow policy in ConvertCart so a
|
||||
// card the user deleted (or that the storage-TTL reconciler removed)
|
||||
// cannot be silently re-charged through retry. The ownership clause is
|
||||
// defensive: order.CardID is enforced at SetCard time today but binding
|
||||
// the retry path to that invariant explicitly costs nothing.
|
||||
var card models.Card
|
||||
if err := db.Where("id = ? AND user_id = ?", order.CardID, userID).First(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusConflict, "CARD_NO_LONGER_AVAILABLE", "saved card is no longer available, add it again to retry", nil)
|
||||
return
|
||||
}
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
stripeStart := time.Now()
|
||||
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
|
||||
PaymentMethod: stripe.String(card.StripePaymentID),
|
||||
OffSession: stripe.Bool(false),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
})
|
||||
stripeDur := time.Since(stripeStart).Seconds()
|
||||
if cerr != nil {
|
||||
middleware.ObserveStripeRequest("payment_intent_confirm", "error", stripeDur)
|
||||
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
audit.OrderFailed(userID, order.ID, order.Total, stripeErr.Msg)
|
||||
middleware.RecordCartConversion("retry_card_declined")
|
||||
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
||||
return
|
||||
}
|
||||
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Code == "payment_intent_unexpected_state" {
|
||||
actual, gerr := paymentintent.Get(order.StripePaymentID, nil)
|
||||
if gerr != nil {
|
||||
middleware.RecordCartConversion("retry_stripe_error")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
|
||||
return
|
||||
}
|
||||
switch actual.Status {
|
||||
case stripe.PaymentIntentStatusSucceeded:
|
||||
middleware.RecordCartConversion("retry_race_already_done")
|
||||
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"status": "succeeded",
|
||||
})
|
||||
return
|
||||
case stripe.PaymentIntentStatusCanceled:
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
audit.OrderFailed(userID, order.ID, order.Total, "payment intent canceled")
|
||||
middleware.RecordCartConversion("retry_pi_canceled")
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "payment intent is canceled, please start a new order", nil)
|
||||
return
|
||||
default:
|
||||
middleware.RecordCartConversion("retry_pi_unexpected_state")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment intent in unexpected state", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
middleware.RecordCartConversion("retry_stripe_error")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
|
||||
return
|
||||
}
|
||||
middleware.ObserveStripeRequest("payment_intent_confirm", "success", stripeDur)
|
||||
|
||||
// frictionless 3DS on retry, stripe approved without a new challenge.
|
||||
// the payment_intent.succeeded webhook owns the order.status=paid
|
||||
// transition (matches the initial-flow contract); tell the TUI to keep
|
||||
// polling /orders/{id}/status until it lands
|
||||
if confirmed.Status == stripe.PaymentIntentStatusSucceeded {
|
||||
middleware.RecordCartConversion("retry_frictionless")
|
||||
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"status": "succeeded",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.respondRequiresAction(w, &order, card.StripePaymentID, confirmed)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3
api/handlers/doc.go
Normal file
3
api/handlers/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package handlers is where the real work happens, one function per endpoint:
|
||||
// carts, orders, products, addresses, cards, webhooks, and the rest.
|
||||
package handlers
|
||||
421
api/handlers/ephemeral_test.go
Normal file
421
api/handlers/ephemeral_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// seedEphemeralCart seeds an item + address on the user's cart but deliberately
|
||||
// NO card — the ephemeral (privacy) path supplies a one-time token at checkout
|
||||
// instead of selecting a saved card.
|
||||
func seedEphemeralCart(t *testing.T, user models.User) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
|
||||
addr := models.Address{
|
||||
UserID: user.ID, Name: "test", Street: "1 Main St",
|
||||
City: "PDX", State: "OR", Zip: "97201", Country: "US",
|
||||
}
|
||||
if err := db.Create(&addr).Error; err != nil {
|
||||
t.Fatalf("seed address: %v", err)
|
||||
}
|
||||
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", 500).Error; err != nil {
|
||||
t.Fatalf("pin coffee price: %v", err)
|
||||
}
|
||||
|
||||
h := NewCartHandler("", 0)
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": 2})
|
||||
w := httptest.NewRecorder()
|
||||
h.SetItem(w, authRequest("PUT", "/api/v1/cart/item", body, user.ID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SetItem: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
w = httptest.NewRecorder()
|
||||
h.SetAddress(w, authRequest("PUT", "/api/v1/cart/address", body, user.ID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertCart_Ephemeral_InlineSuccess proves the privacy charge path: a
|
||||
// one-time PaymentMethod is created from the token, the PaymentIntent attaches
|
||||
// NO Customer, the PM is detached after settle, no Card row is written, and the
|
||||
// order is paid + marked Ephemeral + keeps the PaymentIntent id (so refunds
|
||||
// still work). The table covers both drivers: the per-request flag and the
|
||||
// account-level PrivacyMode switch.
|
||||
func TestConvertCart_Ephemeral_InlineSuccess(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
reqEphem bool // request "ephemeral"
|
||||
}{
|
||||
{"per-request flag", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
seedEphemeralCart(t, user)
|
||||
|
||||
var pmNewToken string
|
||||
var piParams *stripe.PaymentIntentParams
|
||||
var detached string
|
||||
|
||||
origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn
|
||||
defer func() {
|
||||
paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach
|
||||
}()
|
||||
paymentMethodNewFn = func(p *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
|
||||
if p.Card != nil && p.Card.Token != nil {
|
||||
pmNewToken = *p.Card.Token
|
||||
}
|
||||
return &stripe.PaymentMethod{ID: "pm_once"}, nil
|
||||
}
|
||||
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
piParams = p
|
||||
return &stripe.PaymentIntent{ID: "pi_eph", Status: stripe.PaymentIntentStatusSucceeded}, nil
|
||||
}
|
||||
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
|
||||
detached = id
|
||||
return &stripe.PaymentMethod{ID: id}, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"ephemeral": tc.reqEphem, "card_token": "tok_visa"})
|
||||
w := httptest.NewRecorder()
|
||||
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if pmNewToken != "tok_visa" {
|
||||
t.Errorf("one-time PM token: want tok_visa, got %q", pmNewToken)
|
||||
}
|
||||
if piParams == nil {
|
||||
t.Fatal("paymentIntentNewFn was never called")
|
||||
}
|
||||
if piParams.Customer != nil {
|
||||
t.Errorf("ephemeral charge must NOT attach a Customer; got %q", *piParams.Customer)
|
||||
}
|
||||
if piParams.PaymentMethod == nil || *piParams.PaymentMethod != "pm_once" {
|
||||
t.Error("PI must charge the one-time PM pm_once")
|
||||
}
|
||||
if detached != "pm_once" {
|
||||
t.Errorf("PM must be detached after settle; detached=%q", detached)
|
||||
}
|
||||
|
||||
var cardCount int64
|
||||
db.Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount)
|
||||
if cardCount != 0 {
|
||||
t.Errorf("ephemeral checkout must not save a Card row; found %d", cardCount)
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil {
|
||||
t.Fatalf("load order: %v", err)
|
||||
}
|
||||
if !order.Ephemeral {
|
||||
t.Error("order.Ephemeral must be true")
|
||||
}
|
||||
if order.CardID != 0 {
|
||||
t.Errorf("ephemeral order must have CardID 0, got %d", order.CardID)
|
||||
}
|
||||
if order.Status != models.OrderStatusPaid {
|
||||
t.Errorf("status: want paid, got %q", order.Status)
|
||||
}
|
||||
if order.StripePaymentID != "pi_eph" {
|
||||
t.Errorf("order must keep the PaymentIntent id for refunds, got %q", order.StripePaymentID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertCart_Ephemeral_MissingTokenRejected: a privacy checkout with no
|
||||
// card token is a 400, and we never reach Stripe.
|
||||
func TestConvertCart_Ephemeral_MissingTokenRejected(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
seedEphemeralCart(t, user)
|
||||
|
||||
piCalled := false
|
||||
origPI := paymentIntentNewFn
|
||||
defer func() { paymentIntentNewFn = origPI }()
|
||||
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
piCalled = true
|
||||
return &stripe.PaymentIntent{ID: "pi_x", Status: stripe.PaymentIntentStatusSucceeded}, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"ephemeral": true}) // no card_token
|
||||
w := httptest.NewRecorder()
|
||||
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if piCalled {
|
||||
t.Error("must not create a PaymentIntent when the card token is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertCart_Ephemeral_RequiresActionDefersDetach: on a 3DS challenge the
|
||||
// charge has not settled, so the PM must stay attached (the webhook detaches it
|
||||
// later). The order persists Ephemeral so the webhook knows to detach.
|
||||
func TestConvertCart_Ephemeral_RequiresActionDefersDetach(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
seedEphemeralCart(t, user)
|
||||
|
||||
detachCalled := false
|
||||
origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn
|
||||
defer func() {
|
||||
paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach
|
||||
}()
|
||||
paymentMethodNewFn = func(_ *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
|
||||
return &stripe.PaymentMethod{ID: "pm_once_3ds"}, nil
|
||||
}
|
||||
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: "pi_eph_3ds",
|
||||
Status: stripe.PaymentIntentStatusRequiresAction,
|
||||
NextAction: &stripe.PaymentIntentNextAction{
|
||||
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{URL: "https://hooks.stripe.com/3ds/test"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
|
||||
detachCalled = true
|
||||
return &stripe.PaymentMethod{ID: id}, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"ephemeral": true, "card_token": "tok_3ds"})
|
||||
w := httptest.NewRecorder()
|
||||
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if detachCalled {
|
||||
t.Error("must NOT detach the PM while 3DS is pending; the webhook owns the deferred detach")
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Where("user_id = ?", user.ID).First(&order).Error; err != nil {
|
||||
t.Fatalf("load order: %v", err)
|
||||
}
|
||||
if !order.Ephemeral {
|
||||
t.Error("order.Ephemeral must persist so the webhook can detach later")
|
||||
}
|
||||
if order.Status != models.OrderStatusRequiresAction {
|
||||
t.Errorf("status: want requires_action, got %q", order.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentSucceeded_EphemeralDetach exercises the deferred (3DS)
|
||||
// detach: an Ephemeral order finishing via webhook detaches its PM; a
|
||||
// non-ephemeral one does not.
|
||||
func TestHandlePaymentIntentSucceeded_EphemeralDetach(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ephemeral bool
|
||||
wantDetach string // expected detached PM id, "" if none
|
||||
}{
|
||||
{"ephemeral order detaches PM", true, "pm_eph_once"},
|
||||
{"non-ephemeral order keeps PM", false, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
order := models.Order{
|
||||
UserID: user.ID, CardID: 0, Ephemeral: tc.ephemeral,
|
||||
StripePaymentID: "pi_eph_webhook", Status: models.OrderStatusRequiresAction,
|
||||
Subtotal: 500, Total: 500,
|
||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
||||
ShippingCity: "PDX", ShippingState: "OR", ShippingZip: "97201", ShippingCountry: "US",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
|
||||
detached := ""
|
||||
origDetach := paymentMethodDetachFn
|
||||
defer func() { paymentMethodDetachFn = origDetach }()
|
||||
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
|
||||
detached = id
|
||||
return &stripe.PaymentMethod{ID: id}, nil
|
||||
}
|
||||
|
||||
pi := map[string]any{
|
||||
"id": order.StripePaymentID,
|
||||
"amount": order.Total,
|
||||
"currency": "usd",
|
||||
"payment_method": "pm_eph_once",
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||
}
|
||||
raw, _ := json.Marshal(pi)
|
||||
evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}}
|
||||
|
||||
NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Fatalf("status: want paid, got %q", reloaded.Status)
|
||||
}
|
||||
if detached != tc.wantDetach {
|
||||
t.Errorf("detach: want %q, got %q", tc.wantDetach, detached)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertCart_PrivacyModeForgetsCard proves the privacy guarantee on the
|
||||
// saved-card path: a privacy_mode user who checks out a saved (QR-collected) card
|
||||
// is charged normally (Customer attached — the PM is customer-attached, so the
|
||||
// charge requires it), but once the charge settles the PaymentMethod is detached
|
||||
// and the local Card row is hard-deleted, and the order is marked Ephemeral. The
|
||||
// live QR add-card flow has no "save anyway" prompt, so privacy mode is the
|
||||
// authoritative signal here.
|
||||
func TestConvertCart_PrivacyModeForgetsCard(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&user).Update("privacy_mode", true).Error; err != nil {
|
||||
t.Fatalf("set privacy_mode: %v", err)
|
||||
}
|
||||
seedConvertReadyCart(t, user) // seeds a SAVED card + address + item
|
||||
|
||||
var piParams *stripe.PaymentIntentParams
|
||||
detached := ""
|
||||
origPI, origDetach := paymentIntentNewFn, paymentMethodDetachFn
|
||||
defer func() { paymentIntentNewFn, paymentMethodDetachFn = origPI, origDetach }()
|
||||
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
piParams = p
|
||||
return &stripe.PaymentIntent{ID: "pi_forget", Status: stripe.PaymentIntentStatusSucceeded}, nil
|
||||
}
|
||||
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
|
||||
detached = id
|
||||
return &stripe.PaymentMethod{ID: id}, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"ephemeral": false})
|
||||
w := httptest.NewRecorder()
|
||||
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// The PM is attached to the Stripe customer, so the charge must still set
|
||||
// Customer — you cannot charge an attached PM without it.
|
||||
if piParams == nil || piParams.Customer == nil {
|
||||
t.Error("saved-card charge must attach a Customer")
|
||||
}
|
||||
// ...but privacy mode forgets it afterward: detach + hard-delete.
|
||||
if detached != "pm_test_cit" {
|
||||
t.Errorf("forgotten card PM must be detached after settle; detached=%q", detached)
|
||||
}
|
||||
var cardCount int64
|
||||
db.Unscoped().Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount)
|
||||
if cardCount != 0 {
|
||||
t.Errorf("privacy checkout must HARD-delete the local card row; found %d", cardCount)
|
||||
}
|
||||
var order models.Order
|
||||
if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil {
|
||||
t.Fatalf("load order: %v", err)
|
||||
}
|
||||
if !order.Ephemeral {
|
||||
t.Error("forgotten-card order must be marked Ephemeral")
|
||||
}
|
||||
if order.CardID != 0 {
|
||||
t.Errorf("forgotten-card order must null its CardID, got %d", order.CardID)
|
||||
}
|
||||
if order.Status != models.OrderStatusPaid {
|
||||
t.Errorf("status: want paid, got %q", order.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentSucceeded_ForgetsCard covers the deferred (3DS) twin of
|
||||
// the inline forget-card cleanup: an Ephemeral order that still carries a local
|
||||
// Card row (CardID != 0, the privacy + saved/QR-card case) has that row
|
||||
// hard-deleted and its CardID nulled when the webhook marks it paid.
|
||||
func TestHandlePaymentIntentSucceeded_ForgetsCard(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
card := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_forget_webhook",
|
||||
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
order := models.Order{
|
||||
UserID: user.ID, CardID: card.ID, Ephemeral: true,
|
||||
StripePaymentID: "pi_forget_webhook", Status: models.OrderStatusRequiresAction,
|
||||
Subtotal: 500, Total: 500,
|
||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
||||
ShippingCity: "PDX", ShippingState: "OR", ShippingZip: "97201", ShippingCountry: "US",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
|
||||
origDetach := paymentMethodDetachFn
|
||||
defer func() { paymentMethodDetachFn = origDetach }()
|
||||
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
|
||||
return &stripe.PaymentMethod{ID: id}, nil
|
||||
}
|
||||
|
||||
pi := map[string]any{
|
||||
"id": order.StripePaymentID,
|
||||
"amount": order.Total,
|
||||
"currency": "usd",
|
||||
"payment_method": "pm_forget_webhook",
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||
}
|
||||
raw, _ := json.Marshal(pi)
|
||||
evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}}
|
||||
|
||||
NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt)
|
||||
|
||||
var cardCount int64
|
||||
db.Unscoped().Model(&models.Card{}).Where("id = ?", card.ID).Count(&cardCount)
|
||||
if cardCount != 0 {
|
||||
t.Errorf("webhook must hard-delete the forgotten card; found %d", cardCount)
|
||||
}
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Errorf("status: want paid, got %q", reloaded.Status)
|
||||
}
|
||||
if reloaded.CardID != 0 {
|
||||
t.Errorf("order CardID must be nulled, got %d", reloaded.CardID)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,177 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/refund"
|
||||
"terminalShop/pkg/bring"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/shipping"
|
||||
"terminalShop/pkg/shippo"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/refund"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OrderHandler struct {
|
||||
stripeKey string
|
||||
bringAPIUID string
|
||||
bringAPIKey string
|
||||
bringCustomerNumber string
|
||||
}
|
||||
|
||||
func NewOrderHandler(stripeSecretKey string) *OrderHandler {
|
||||
return &OrderHandler{stripeKey: stripeSecretKey}
|
||||
type refundRequestPayload struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// refundRequestCooldown is the minimum interval between customer-initiated
|
||||
// refund requests for the same order. Prevents accidental double-clicks and
|
||||
// repeated submissions from spamming the operator's Slack channel.
|
||||
const refundRequestCooldown = 15 * time.Minute
|
||||
|
||||
// NewOrderHandler creates an order handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
return &OrderHandler{
|
||||
bringAPIUID: bringAPIUID,
|
||||
bringAPIKey: bringAPIKey,
|
||||
bringCustomerNumber: bringCustomerNumber,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRefundRequest posts a customer refund request into the order's Slack
|
||||
// thread. It does not issue a Stripe refund.
|
||||
func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req refundRequestPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.Message = strings.TrimSpace(req.Message)
|
||||
|
||||
if !models.IsValidRefundReason(req.Reason) {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil)
|
||||
return
|
||||
}
|
||||
if req.Reason == models.RefundRequestReasonOther && req.Message == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Preload("User").Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
if !order.CanRequestRefund() {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
|
||||
"only paid, shipped, or delivered orders can request refunds", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if order.LastRefundRequestAt != nil {
|
||||
elapsed := time.Since(*order.LastRefundRequestAt)
|
||||
if elapsed < refundRequestCooldown {
|
||||
retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED",
|
||||
fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1),
|
||||
nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f",
|
||||
req.Reason, order.User.Name, order.User.Email, order.TotalInDollars())
|
||||
if req.Message != "" {
|
||||
quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ")
|
||||
text += "\n\n" + quoted
|
||||
}
|
||||
|
||||
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Record the timestamp after a successful Slack post so a Slack outage
|
||||
// doesn't consume the user's cooldown — they can retry immediately.
|
||||
if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil {
|
||||
slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"status": "sent",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var orders []models.Order
|
||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
|
||||
db.Where("user_id = ?", userID).
|
||||
Preload("Items").
|
||||
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||
Order("created_at desc").Find(&orders)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"orders": orders,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"id": order.ID,
|
||||
"status": string(order.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// RefundOrder issues a full refund for an order via Stripe and marks it as
|
||||
// refunded. Only orders in paid or shipped status can be refunded.
|
||||
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -67,8 +200,6 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
rf, err := refund.New(&stripe.RefundParams{
|
||||
PaymentIntent: stripe.String(order.StripePaymentID),
|
||||
})
|
||||
@@ -88,8 +219,186 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"refund_id": rf.ID,
|
||||
"status": "refunded",
|
||||
})
|
||||
}
|
||||
|
||||
// updateTrackingRequest is the JSON body accepted by UpdateTracking.
|
||||
// shipped_at defaults to now if not otherwise specified
|
||||
type updateTrackingRequest struct {
|
||||
Carrier string `json:"carrier"`
|
||||
TrackingNumber string `json:"tracking_number"`
|
||||
TrackingURL string `json:"tracking_url"`
|
||||
ShippedAt *time.Time `json:"shipped_at"`
|
||||
}
|
||||
|
||||
// UpdateTracking sets carrier metadata on the order. Transitions paid orders
|
||||
// to shipped automatically. Admin only, gated by RequireAdmin in routes.go,
|
||||
// not RequireAuth, so no user JWT is needed.
|
||||
func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateTrackingRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
if req.Carrier == "" || req.TrackingNumber == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELDS", "carrier and or tracking_number fields are required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("id = ?", id).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
shippedAt := req.ShippedAt
|
||||
if shippedAt == nil {
|
||||
now := time.Now()
|
||||
shippedAt = &now
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"carrier": req.Carrier,
|
||||
"tracking_number": req.TrackingNumber,
|
||||
"tracking_url": req.TrackingURL,
|
||||
"shipped_at": shippedAt,
|
||||
}
|
||||
if order.Status == models.OrderStatusPaid {
|
||||
updates["status"] = models.OrderStatusShipped
|
||||
}
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to record shipment", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"carrier": req.Carrier,
|
||||
"tracking_number": req.TrackingNumber,
|
||||
"status": order.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// PurchaseLabel buys a shipping label from shippo for the order, writes
|
||||
// the carrier, tracking, and label fields onto the order, and changes the status
|
||||
// from paid to shipped
|
||||
func (h *OrderHandler) PurchaseLabel(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Preload("Items.Coffee").Preload("User").Where("id = ?", id).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if order.TrackingNumber != "" {
|
||||
utils.RespondError(w, http.StatusConflict, "ALREADY_LABELED", "order already has a tracking number", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if order.Status != models.OrderStatusPaid {
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "only paid orders can be labeled", nil)
|
||||
return
|
||||
}
|
||||
|
||||
const ozToKg = 0.0283495
|
||||
const defaultOunces = 12.0
|
||||
|
||||
totalKg := 0.0
|
||||
bringItems := make([]bring.LineItem, 0, len(order.Items))
|
||||
shippoItems := make([]shippo.LineItem, 0, len(order.Items))
|
||||
for _, item := range order.Items {
|
||||
oz := float64(item.Coffee.Ounces)
|
||||
if oz <= 0 {
|
||||
oz = defaultOunces
|
||||
}
|
||||
kg := oz * ozToKg
|
||||
totalKg += float64(item.Quantity) * kg
|
||||
bringItems = append(bringItems, bring.LineItem{Title: item.Name, Quantity: item.Quantity, WeightKg: kg})
|
||||
shippoItems = append(shippoItems, shippo.LineItem{Title: item.Name, Quantity: item.Quantity, WeightKg: kg})
|
||||
}
|
||||
|
||||
var result *shipping.LabelResult
|
||||
switch order.ShippingCountry {
|
||||
case "US":
|
||||
to := shippo.Address{
|
||||
Name: order.ShippingName, Street1: order.ShippingStreet, Street2: order.ShippingStreet2,
|
||||
City: order.ShippingCity, State: order.ShippingState, Country: order.ShippingCountry,
|
||||
Zip: order.ShippingZip, Phone: order.ShippingPhone,
|
||||
}
|
||||
client := shippo.NewClient(os.Getenv("SHIPPO_API_KEY"))
|
||||
result, err = client.CreateLabel(r.Context(), to, order.User.Email, shippoItems)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SHIPPO_ERROR", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
case "NO":
|
||||
to := bring.BookingAddress{
|
||||
Name: order.ShippingName, Street1: order.ShippingStreet, Street2: order.ShippingStreet2,
|
||||
City: order.ShippingCity, Country: order.ShippingCountry, Zip: order.ShippingZip,
|
||||
Phone: order.ShippingPhone,
|
||||
}
|
||||
testMode := os.Getenv("ENVIRONMENT") != "production"
|
||||
client := bring.NewBookingClient(h.bringAPIUID, h.bringAPIKey, h.bringCustomerNumber, testMode)
|
||||
result, err = client.CreateLabel(r.Context(), to, order.User.Email, bringItems)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "BRING_ERROR", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
default:
|
||||
utils.RespondError(w, http.StatusBadRequest, "UNSUPPORTED_DESTINATION", "no label vendor configured for this country", nil)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"carrier": result.Carrier,
|
||||
"tracking_number": result.TrackingNumber,
|
||||
"tracking_url": result.TrackingURL,
|
||||
"shipped_at": &now,
|
||||
"status": models.OrderStatusShipped,
|
||||
"shippo_transaction_id": result.TransactionID,
|
||||
"label_url": result.LabelURL,
|
||||
"label_cost_cents": result.CostCents,
|
||||
}
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
audit.LabelOrphaned(order.ID, result.TransactionID, result.TrackingNumber, err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "label purchased but failed to record on order; ops will reconcile", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.LabelPurchased(order.ID, result.Carrier, result.TrackingNumber, result.TransactionID, result.CostCents)
|
||||
go notify.SlackUnpinOrder(order.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"carrier": result.Carrier,
|
||||
"service": result.ServiceLevel,
|
||||
"tracking_number": result.TrackingNumber,
|
||||
"tracking_url": result.TrackingURL,
|
||||
"label_url": result.LabelURL,
|
||||
"cost_cents": result.CostCents,
|
||||
"status": models.OrderStatusShipped,
|
||||
})
|
||||
}
|
||||
|
||||
170
api/handlers/orders_test.go
Normal file
170
api/handlers/orders_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func setupOrderTestDB(t *testing.T) models.User {
|
||||
t.Helper()
|
||||
testDB := filepath.Join(t.TempDir(), "orders.db")
|
||||
database.ResetForTesting()
|
||||
t.Cleanup(database.ResetForTesting)
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
if err := database.Seed(db); err != nil {
|
||||
t.Fatalf("failed to seed: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:ordertestfingerprint",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA ordertestkey",
|
||||
Name: "Order Test User",
|
||||
Email: "orders@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request {
|
||||
req := authRequest(method, url, body, userID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", orderID)
|
||||
return req.WithContext(contextWithRoute(req.Context(), routeCtx))
|
||||
}
|
||||
|
||||
func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context {
|
||||
return context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Other","message":" "}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "MESSAGE_REQUIRED" {
|
||||
t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
db := database.GetDB()
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: 1,
|
||||
Status: models.OrderStatusFailed,
|
||||
Subtotal: 100,
|
||||
ShippingCost: 0,
|
||||
Total: 100,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "INVALID_STATE" {
|
||||
t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
db := database.GetDB()
|
||||
recent := time.Now().Add(-1 * time.Minute)
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: 1,
|
||||
Status: models.OrderStatusPaid,
|
||||
Subtotal: 100,
|
||||
ShippingCost: 0,
|
||||
Total: 100,
|
||||
LastRefundRequestAt: &recent,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected status 429, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Retry-After"); got == "" {
|
||||
t.Errorf("expected Retry-After header, got empty")
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "RATE_LIMITED" {
|
||||
t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func NewProductHandler() *ProductHandler {
|
||||
|
||||
// GetProducts returns all products from the database
|
||||
func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
var products []models.Coffee
|
||||
|
||||
@@ -30,14 +30,14 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"products": products,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProduct returns a single product by ID
|
||||
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
// Get ID from URL parameter
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -53,8 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string {
|
||||
testDB := "test_products.db"
|
||||
|
||||
// Remove if exists
|
||||
os.Remove(testDB)
|
||||
_ = os.Remove(testDB)
|
||||
|
||||
// Reset singleton
|
||||
database.ResetForTesting()
|
||||
@@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string {
|
||||
|
||||
func TestGetProduct(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
@@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) {
|
||||
|
||||
func TestGetProductNotFound(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
@@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) {
|
||||
|
||||
func TestGetProductsEmptyDatabase(t *testing.T) {
|
||||
testDB := "test_empty.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
// Create database but don't seed
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// paymentIntentGetFn lets tests stub the Stripe Get call without spinning
|
||||
// up an httptest backend. Production callers go straight through to the
|
||||
// real paymentintent.Get.
|
||||
var paymentIntentGetFn = paymentintent.Get
|
||||
|
||||
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
|
||||
|
||||
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
|
||||
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
|
||||
// the case where the server crashed after charging but before the DB transaction
|
||||
// completed.
|
||||
func ReconcileOrders(stripeKey string) {
|
||||
stripe.Key = stripeKey
|
||||
db := database.GetDB()
|
||||
func ReconcileOrders(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
@@ -30,22 +40,28 @@ func ReconcileOrders(stripeKey string) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[reconcile] checking %d pending orders", len(orders))
|
||||
reconcileLog().Info("checking pending orders", "count", len(orders))
|
||||
|
||||
for _, order := range orders {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
params := &stripe.PaymentIntentSearchParams{
|
||||
SearchParams: stripe.SearchParams{
|
||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||
Context: ctx,
|
||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||
},
|
||||
}
|
||||
|
||||
iter := paymentintent.Search(params)
|
||||
for iter.Next() {
|
||||
pi := iter.PaymentIntent()
|
||||
log.Printf("[reconcile] found succeeded PI %s for order %d — patching", pi.ID, order.ID)
|
||||
reconcileLog().Warn("found succeeded payment intent for unrecorded order — patching",
|
||||
"order_id", order.ID, "pi", pi.ID)
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
@@ -58,17 +74,193 @@ func ReconcileOrders(stripeKey string) {
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
return tx.Model(&cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
log.Printf("[reconcile] failed to patch order %d: %v", order.ID, txErr)
|
||||
reconcileLog().Error("failed to patch order", "order_id", order.ID, "error", txErr)
|
||||
}
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
log.Printf("[reconcile] stripe search error for order %d: %v", order.ID, err)
|
||||
reconcileLog().Error("stripe search error", "order_id", order.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileStale3DSOrders flips requires_action orders that have been
|
||||
// awaiting the customer's 3DS challenge longer than threshold to failed,
|
||||
// after re-checking the PaymentIntent with Stripe so a late
|
||||
// payment_intent.succeeded webhook is respected. Deliberately separate
|
||||
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
|
||||
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
|
||||
// stripe.Key is wired once at startup in api/main.go.
|
||||
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
// Predicate keys off updated_at, not created_at: respondRequiresAction
|
||||
// and RetryAuth both bump updated_at via GORM auto-stamping, so every
|
||||
// customer-driven retry extends the grace window. Intentional - sweeping
|
||||
// a row the customer just re-armed would race the new challenge.
|
||||
cutoff := time.Now().Add(-threshold)
|
||||
if err := db.Where("status = ? AND updated_at < ?",
|
||||
models.OrderStatusRequiresAction, cutoff).Find(&orders).Error; err != nil {
|
||||
reconcileLog().Error("stale 3ds scan failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
|
||||
|
||||
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
|
||||
for i := range orders {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
order := &orders[i]
|
||||
if order.StripePaymentID == "" {
|
||||
// respondRequiresAction writes stripe_payment_id and status in
|
||||
// one UPDATE so this should be unreachable; guard anyway so a
|
||||
// future regression noisily logs instead of 4xx'ing Stripe.
|
||||
reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID)
|
||||
middleware.RecordAbandoned3DSSweep("missing_pi")
|
||||
continue
|
||||
}
|
||||
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
|
||||
if err != nil {
|
||||
reconcileLog().Error("stripe get failed",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
|
||||
middleware.RecordAbandoned3DSSweep("stripe_error")
|
||||
continue
|
||||
}
|
||||
switch actual.Status {
|
||||
case stripe.PaymentIntentStatusSucceeded:
|
||||
// Stripe says paid but our row still says requires_action, the
|
||||
// payment_intent.succeeded webhook has not landed yet (or was
|
||||
// dropped). Defer to handlePaymentIntentsucceeded; it owns the
|
||||
// paid transition + cart cleanup atomically. The next sweep
|
||||
// tick re-checks and surfaces the dropped webhook if it persists
|
||||
reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID)
|
||||
middleware.RecordAbandoned3DSSweep("succeeded_skip")
|
||||
case stripe.PaymentIntentStatusProcessing,
|
||||
stripe.PaymentIntentStatusRequiresCapture:
|
||||
reconcileLog().Info("payment intent still transitioning; skipping",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
|
||||
middleware.RecordAbandoned3DSSweep("processing_skip")
|
||||
case stripe.PaymentIntentStatusRequiresAction,
|
||||
stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
stripe.PaymentIntentStatusRequiresConfirmation,
|
||||
stripe.PaymentIntentStatusCanceled:
|
||||
flipStale3DSToFailed(db, order, string(actual.Status))
|
||||
default:
|
||||
reconcileLog().Warn("unrecognized stripe status; skipping",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
|
||||
middleware.RecordAbandoned3DSSweep("unrecognized_status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flipStale3DSToFailed transitions a single stale requires_action row
|
||||
// to failed. The UPDATE is conditional on status = requires_action so a
|
||||
// payment_intent.succeeded webhook that landed between the stripe get and
|
||||
// this write (caveat #20 race) wins and we silently no-op
|
||||
func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string) {
|
||||
// Map form so the single-field UPDATE is explicit at the call site.
|
||||
// GORM still auto-stamps updated_at; the row exits the sweep predicate
|
||||
// naturally on the next tick because status no longer matches anyway.
|
||||
res := db.Model(&models.Order{}).
|
||||
Where("id = ? AND status = ?", order.ID, models.OrderStatusRequiresAction).
|
||||
Updates(map[string]any{"status": models.OrderStatusFailed})
|
||||
if res.Error != nil {
|
||||
reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error)
|
||||
middleware.RecordAbandoned3DSSweep("flip_failed")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
|
||||
"order_id", order.ID)
|
||||
middleware.RecordAbandoned3DSSweep("flip_noop")
|
||||
return
|
||||
}
|
||||
audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID)
|
||||
reconcileLog().Info("3ds abandoned order flipped to failed",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus)
|
||||
middleware.RecordAbandoned3DSSweep("flipped")
|
||||
}
|
||||
|
||||
// ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
|
||||
// Read paths filter out expired rows so users never see them; this job drives
|
||||
// the Stripe detach + audit + physical row delete out of band so request
|
||||
// handlers never block on Stripe API calls during a card sweep.
|
||||
func ReconcileExpiredCards(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var cards []models.Card
|
||||
if err := db.Where(
|
||||
"storage_expires_at IS NOT NULL AND storage_expires_at <= ?",
|
||||
time.Now(),
|
||||
).Find(&cards).Error; err != nil {
|
||||
reconcileLog().Error("expired card scan failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
|
||||
for i := range cards {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
if err := expireStoredCard(db, &cards[i]); err != nil {
|
||||
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed.
|
||||
// Lazy deletion on read (PayRedirect) is the primary path, this sweep only
|
||||
// reclaims tokens nobody ever followed so the table stays bounded.
|
||||
func ReconcilePayRedirects(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
res := db.Where("expires_at < ?", time.Now()).Delete(&models.PayRedirect{})
|
||||
if res.Error != nil {
|
||||
reconcileLog().Error("pay_redirects sweep failed", "error", res.Error)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return
|
||||
}
|
||||
reconcileLog().Info("swept expired pay_redirects", "count", res.RowsAffected)
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid orders older than 24h with no tracking
|
||||
// number and posts a single Slack reminder. Pure read + notify.
|
||||
func ReconcileUnshipped(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
err := db.Where(
|
||||
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
|
||||
models.OrderStatusPaid, cutoff,
|
||||
).Order("created_at ASC").Find(&orders).Error
|
||||
if err != nil {
|
||||
reconcileLog().Error("unshipped query failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
reconcileLog().Info("unshipped orders found", "count", len(orders))
|
||||
notify.SlackUnshippedReminder(orders)
|
||||
}
|
||||
|
||||
598
api/handlers/reconcile_test.go
Normal file
598
api/handlers/reconcile_test.go
Normal file
@@ -0,0 +1,598 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
)
|
||||
|
||||
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
|
||||
// and Stripe-Get branching: a row past the threshold whose PaymentIntent
|
||||
// Stripe still reports as requires_payment_method (the customer never
|
||||
// completed the challenge) transitions to failed and emits the new
|
||||
// order_3ds_abandoned audit row.
|
||||
func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_stale_abandoned",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
|
||||
before := testutil.ToFloat64(flipped)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusFailed {
|
||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
||||
}
|
||||
|
||||
var events []models.OrderEvent
|
||||
if err := db.Where("order_id = ? AND type = ?",
|
||||
order.ID, audit.EventOrder3DSAbandoned).Find(&events).Error; err != nil {
|
||||
t.Fatalf("list events: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
|
||||
}
|
||||
if got := testutil.ToFloat64(flipped) - before; got != 1 {
|
||||
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
|
||||
// excludes rows still within the grace window. A fresh requires_action row
|
||||
// must not even reach the Stripe Get call.
|
||||
func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_fresh_3ds",
|
||||
time.Now().Add(-5*time.Minute))
|
||||
|
||||
called := false
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
called = true
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
if called {
|
||||
t.Fatalf("stripe Get called for in-window row")
|
||||
}
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("status changed unexpectedly: %q", reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded covers the
|
||||
// webhook-race guard from caveat #20. If Stripe-side already says succeeded,
|
||||
// the sweep must defer to handlePaymentIntentSucceeded rather than overwrite
|
||||
// the authoritative end state.
|
||||
func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_succeeded_late_webhook",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusSucceeded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
|
||||
before := testutil.ToFloat64(skip)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(skip) - before; got != 1 {
|
||||
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
|
||||
// UPDATE. If the row flipped to paid between the Stripe Get and the flip,
|
||||
// the WHERE status = requires_action clause prevents clobber.
|
||||
func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_race_paid",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
db.Model(&models.Order{}).Where("id = ?", order.ID).
|
||||
Update("status", models.OrderStatusPaid)
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
|
||||
before := testutil.ToFloat64(noop)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(noop) - before; got != 1 {
|
||||
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
|
||||
// Stripe-terminal-canceled maps to OrderStatusFailed (matches RetryAuth's
|
||||
// existing precedent — see api/handlers/cart.go RetryAuth canceled branch).
|
||||
func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_canceled_stale",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusCanceled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusFailed {
|
||||
t.Fatalf("canceled PI must flip to failed; got %q", reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_DoesNotConsumeRetryCap regression-tests the
|
||||
// caveat #17 decoupling: the new sweep emits EventOrder3DSAbandoned, never
|
||||
// EventOrderRequiresAction, so a swept-then-stuck order does not silently
|
||||
// shrink the retry ceiling for any future user-driven retry attempts.
|
||||
func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_decoupled_cap",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var retryCount int64
|
||||
if err := db.Model(&models.OrderEvent{}).
|
||||
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
|
||||
Count(&retryCount).Error; err != nil {
|
||||
t.Fatalf("count retry events: %v", err)
|
||||
}
|
||||
if retryCount != 0 {
|
||||
t.Fatalf("sweep must not emit EventOrderRequiresAction; got %d", retryCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsTransitioningStatuses covers the
|
||||
// processing and requires_capture branches: Stripe still owns the PI's
|
||||
// next move, so the sweep must not flip even past the threshold.
|
||||
func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status stripe.PaymentIntentStatus
|
||||
}{
|
||||
{"processing", stripe.PaymentIntentStatusProcessing},
|
||||
{"requires_capture", stripe.PaymentIntentStatusRequiresCapture},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID,
|
||||
"pi_transitioning_"+tc.name, time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
|
||||
}
|
||||
|
||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
|
||||
before := testutil.ToFloat64(skip)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(skip) - before; got != 1 {
|
||||
t.Fatalf("want 1 processing_skip increment, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsUnknownStatus covers the switch's default
|
||||
// arm: an unrecognized Stripe status logs and skips rather than guessing a
|
||||
// transition. Defends against new PI statuses appearing in future SDKs.
|
||||
func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_unknown_status",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatus("some_new_future_status"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
|
||||
before := testutil.ToFloat64(unknown)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(unknown) - before; got != 1 {
|
||||
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
|
||||
// failure logs and leaves the row alone. Transient network errors must not
|
||||
// look like an abandoned 3DS.
|
||||
func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_stripe_error",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
return nil, fmt.Errorf("simulated stripe outage")
|
||||
}
|
||||
|
||||
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
|
||||
before := testutil.ToFloat64(stripeErr)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
|
||||
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
|
||||
// defensive empty-PI short-circuit: even past the threshold, a row missing
|
||||
// stripe_payment_id must not reach paymentintent.Get (which would 4xx).
|
||||
func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
order := seedRequiresActionOrder(t, user.ID, "pi_will_be_blanked",
|
||||
time.Now().Add(-1*time.Hour))
|
||||
if err := db.Model(&order).UpdateColumn("stripe_payment_id", "").Error; err != nil {
|
||||
t.Fatalf("blank PI id: %v", err)
|
||||
}
|
||||
|
||||
called := false
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
called = true
|
||||
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
|
||||
}
|
||||
|
||||
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
|
||||
before := testutil.ToFloat64(missing)
|
||||
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
if called {
|
||||
t.Fatalf("stripe Get called for empty-PI row")
|
||||
}
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
|
||||
}
|
||||
if got := testutil.ToFloat64(missing) - before; got != 1 {
|
||||
t.Fatalf("want 1 missing_pi increment, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
|
||||
// ctx.Err() check stops the sweep once the supervising context cancels, so a
|
||||
// SIGTERM during a large stale-3DS batch does not block the binary past the
|
||||
// 30s graceful-shutdown window in api/main.go
|
||||
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
|
||||
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
calls := 0
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
cancel()
|
||||
}
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
ReconcileStale3DSOrders(ctx, 30*time.Minute)
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
|
||||
}
|
||||
var stillPending int64
|
||||
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
|
||||
t.Fatalf("count requires_action rows: %v", err)
|
||||
}
|
||||
if stillPending < 1 {
|
||||
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRequiresActionOrder inserts a card + order pair in the requires_action
|
||||
// state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of
|
||||
// UpdatedAt on Create does not bump the row out of the sweep predicate.
|
||||
func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt time.Time) models.Order {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
card := models.Card{
|
||||
UserID: userID,
|
||||
StripePaymentID: "pm_test_" + piID,
|
||||
Last4: "4242", Brand: "Visa",
|
||||
ExpMonth: 12, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
order := models.Order{
|
||||
UserID: userID, CardID: card.ID,
|
||||
StripePaymentID: piID,
|
||||
Status: models.OrderStatusRequiresAction,
|
||||
Subtotal: 500, Total: 500,
|
||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
||||
ShippingCity: "PDX", ShippingState: "OR",
|
||||
ShippingZip: "97201", ShippingCountry: "US",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
if err := db.Model(&order).UpdateColumn("updated_at", updatedAt).Error; err != nil {
|
||||
t.Fatalf("backdate updated_at: %v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// TestReconcilePayRedirects_DeleteExpiredKeepsLive verifies the predicate
|
||||
// boundary: a row past ExpiresAt is hard-deleted while a row whose ExpiresAt
|
||||
// is still in the future survives. Seeding both sides through GORM keeps the
|
||||
// stored timestamp format identical to the one the sweep binds for time.Now(),
|
||||
// so the comparison is exercised exactly as it runs in production.
|
||||
func TestReconcilePayRedirects_DeleteExpiredKeepsLive(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
now := time.Now()
|
||||
expired := models.PayRedirect{
|
||||
Token: "tok_expired",
|
||||
URL: "https://example.test/expired",
|
||||
Purpose: models.RedirectPurposeCheckout3DS,
|
||||
CreatedAt: now.Add(-20 * time.Minute),
|
||||
ExpiresAt: now.Add(-10 * time.Minute),
|
||||
}
|
||||
|
||||
live := models.PayRedirect{
|
||||
Token: "tok_live",
|
||||
URL: "https://example.test/live",
|
||||
Purpose: models.RedirectPurposeAddCard,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(10 * time.Minute),
|
||||
}
|
||||
if err := db.Create(&expired).Error; err != nil {
|
||||
t.Fatalf("seed expired: %v", err)
|
||||
}
|
||||
if err := db.Create(&live).Error; err != nil {
|
||||
t.Fatalf("seed live: %v", err)
|
||||
}
|
||||
|
||||
ReconcilePayRedirects(context.Background())
|
||||
|
||||
var remaining []models.PayRedirect
|
||||
if err := db.Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("list remaining: %v", err)
|
||||
}
|
||||
if len(remaining) != 1 {
|
||||
t.Fatalf("want 1 row remaining, got %d", len(remaining))
|
||||
}
|
||||
if remaining[0].Token != "tok_live" {
|
||||
t.Fatalf("wrong row survived: want tok_live, got %q", remaining[0].Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcilePayRedirects_NoExpiredIsNoOp(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
live := models.PayRedirect{
|
||||
Token: "tok_live_only",
|
||||
URL: "https://example.test/live",
|
||||
Purpose: models.RedirectPurposeCheckout3DS,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
if err := db.Create(&live).Error; err != nil {
|
||||
t.Fatalf("seed live: %v", err)
|
||||
}
|
||||
|
||||
ReconcilePayRedirects(context.Background())
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.PayRedirect{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("want 1 row preserved, got %d", count)
|
||||
}
|
||||
}
|
||||
280
api/handlers/slack.go
Normal file
280
api/handlers/slack.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SlackHandler struct {
|
||||
signingSecret string
|
||||
adminKey string
|
||||
apiPort string
|
||||
shippoSecret string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewSlackHandler(signingSecret, adminKey, apiPort, shippoWebhookSecret string) *SlackHandler {
|
||||
return &SlackHandler{
|
||||
signingSecret: signingSecret,
|
||||
adminKey: adminKey,
|
||||
apiPort: apiPort,
|
||||
shippoSecret: shippoWebhookSecret,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type slackAction struct {
|
||||
ActionID string `json:"action_id"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type slackInteractivePayload struct {
|
||||
Type string `json:"type"`
|
||||
ResponseURL string `json:"response_url"`
|
||||
Actions []slackAction `json:"actions"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
func (h *SlackHandler) HandleInteractivity(w http.ResponseWriter, r *http.Request) {
|
||||
if h.signingSecret == "" {
|
||||
utils.RespondError(w, http.StatusServiceUnavailable, "SLACK_NOT_CONFIGURED", "slack signing secret not set", nil)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "READ_FAILED", "could not read body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.verifySignature(r, body) {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "slack signature mismatch", nil)
|
||||
return
|
||||
}
|
||||
|
||||
form, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "BAD_FORM", "could not parse form body", nil)
|
||||
return
|
||||
}
|
||||
rawPayload := form.Get("payload")
|
||||
if rawPayload == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_PAYLOAD", "no payload field", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var p slackInteractivePayload
|
||||
if err := json.Unmarshal([]byte(rawPayload), &p); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "BAD_JSON_PAYLOAD", "could not parse payload", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(p.Actions) == 0 {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
action := p.Actions[0]
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
go h.dispatch(action, p)
|
||||
}
|
||||
|
||||
func (h *SlackHandler) dispatch(action slackAction, p slackInteractivePayload) {
|
||||
switch action.ActionID {
|
||||
case "buy_label":
|
||||
h.handleBuyLabel(action.Value, p)
|
||||
case "mark_pre_transit":
|
||||
h.handleMarkStatus(action.Value, p, "PRE_TRANSIT")
|
||||
case "mark_in_transit":
|
||||
h.handleMarkStatus(action.Value, p, "TRANSIT")
|
||||
case "mark_delivered":
|
||||
h.handleMarkStatus(action.Value, p, "DELIVERED")
|
||||
default:
|
||||
slog.Warn("slack interactivity: unknown action", "action_id", action.ActionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePayload) {
|
||||
orderID64, err := strconv.ParseUint(orderIDStr, 10, 32)
|
||||
if err != nil {
|
||||
slog.Warn("slack buy_label: invalid order id", "value", orderIDStr)
|
||||
return
|
||||
}
|
||||
orderID := uint(orderID64)
|
||||
|
||||
url := fmt.Sprintf("http://localhost:%s/api/v1/orders/%d/label", h.apiPort, orderID)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, url, nil)
|
||||
req.Header.Set("X-Admin-Key", h.adminKey)
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
TrackingNumber string `json:"tracking_number"`
|
||||
Carrier string `json:"carrier"`
|
||||
LabelURL string `json:"label_url"`
|
||||
CostCents int `json:"cost_cents"`
|
||||
} `json:"data"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
_ = json.Unmarshal(bodyBytes, &result)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
||||
return
|
||||
}
|
||||
|
||||
notify.SlackPostLabelPurchased(orderID, result.Data.LabelURL, result.Data.CostCents)
|
||||
|
||||
}
|
||||
|
||||
func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePayload, status string) {
|
||||
orderID64, err := strconv.ParseUint(orderIDStr, 10, 32)
|
||||
if err != nil {
|
||||
slog.Warn("slack mark_status: invalid order id", "value", orderIDStr)
|
||||
return
|
||||
}
|
||||
orderID := uint(orderID64)
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Select("id", "tracking_number", "carrier").Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||
slog.Warn("slack mark_status: order lookup failed", "order_id", orderID, "error", err)
|
||||
return
|
||||
}
|
||||
if order.TrackingNumber == "" {
|
||||
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
||||
return
|
||||
}
|
||||
|
||||
detail := defaultDetailFor(status)
|
||||
payload := map[string]any{
|
||||
"event": "track_updated",
|
||||
"data": map[string]any{
|
||||
"tracking_number": order.TrackingNumber,
|
||||
"carrier": strings.ToLower(order.Carrier),
|
||||
"tracking_status": map[string]any{
|
||||
"status": status,
|
||||
"status_date": time.Now().UTC().Format(time.RFC3339),
|
||||
"status_details": detail,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
webhookURL := fmt.Sprintf("http://localhost:%s/api/v1/webhooks/shippo?token=%s", h.apiPort, url.QueryEscape(h.shippoSecret))
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 300 {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
audit.TrackingMarkedManually(orderID, status, p.User.Username)
|
||||
|
||||
h.editClearActions(p.ResponseURL, status, p.User.Username)
|
||||
}
|
||||
|
||||
func defaultDetailFor(status string) string {
|
||||
switch status {
|
||||
case "PRE_TRANSIT":
|
||||
return "Marked pre-transit by operator"
|
||||
case "TRANSIT":
|
||||
return "Marked transit by operator"
|
||||
case "DELIVERED":
|
||||
return "Marked delivered by operator"
|
||||
}
|
||||
return "Marked by operator"
|
||||
}
|
||||
|
||||
func (h *SlackHandler) editClearActions(responseURL, status, actor string) {
|
||||
if responseURL == "" {
|
||||
return
|
||||
}
|
||||
stamp := fmt.Sprintf(":white_check_mark: Marked %s by @%s at %s", strings.ToLower(status), actor, time.Now().UTC().Format("15:04 MST"))
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"replace_original": true,
|
||||
"text": stamp,
|
||||
"blocks": []map[string]any{
|
||||
{"type": "context", "elements": []map[string]any{
|
||||
{"type": "mrkdwn", "text": stamp},
|
||||
}},
|
||||
},
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, responseURL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("slack mark_status: response_url edit failed", "error", err)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
|
||||
tsHeader := r.Header.Get("X-Slack-Request-Timestamp")
|
||||
sigHeader := r.Header.Get("X-Slack-Signature")
|
||||
if tsHeader == "" || sigHeader == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(tsHeader, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if abs(time.Now().Unix()-ts) > 60*5 {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(h.signingSecret))
|
||||
_, _ = fmt.Fprintf(mac, "v0:%d:%s", ts, body)
|
||||
expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(expected), []byte(sigHeader))
|
||||
}
|
||||
|
||||
func abs(x int64) int64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
var _ = os.Getenv
|
||||
33
api/handlers/tracking.go
Normal file
33
api/handlers/tracking.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// TrackingRedirect resolves an order ID and 302s to the carrier's tracking URL.
|
||||
// Public (no auth) so the URL stays short enough to copy from any TUI width.
|
||||
func TrackingRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, "bad order id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Select("tracking_url").Where("id = ?", uint(id)).First(&order).Error; err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if order.TrackingURL == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, order.TrackingURL, http.StatusFound)
|
||||
}
|
||||
@@ -2,13 +2,17 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ViewHandler handles bulk TUI bootstrap data
|
||||
type ViewHandler struct{}
|
||||
|
||||
func NewViewHandler() *ViewHandler {
|
||||
@@ -16,7 +20,7 @@ func NewViewHandler() *ViewHandler {
|
||||
}
|
||||
|
||||
func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var user models.User
|
||||
@@ -35,16 +39,22 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db.Where("user_id = ?", userID).Find(&addresses)
|
||||
|
||||
var cards []models.Card
|
||||
db.Where("user_id = ?", userID).Find(&cards)
|
||||
db.Where(
|
||||
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
|
||||
userID, time.Now(),
|
||||
).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||
|
||||
var orders []models.Order
|
||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
|
||||
db.Where("user_id = ?", userID).
|
||||
Preload("Items").
|
||||
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||
Order("created_at DESC").Find(&orders)
|
||||
var cartItems []models.CartItem
|
||||
if cart.ID != 0 {
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
"products": products,
|
||||
"cart": cartItems,
|
||||
|
||||
70
api/handlers/view_test.go
Normal file
70
api/handlers/view_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// TestGetViewInit_FiltersExpiredCardsAndOrders drives the WHERE + ORDER BY
|
||||
// clauses in GetViewInit. a column-name typo in either clause (e.g.
|
||||
// id_default instead of is_default) surfaces here as a sqlite error
|
||||
func TestGetViewInit_FiltersExpiredCardsAndOrders(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
now := time.Now()
|
||||
past := now.Add(-1 * time.Hour)
|
||||
future := now.Add((24 * time.Hour))
|
||||
|
||||
expired := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
||||
}
|
||||
freshDefault := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_fresh_d", Last4: "2222",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
||||
IsDefault: true,
|
||||
}
|
||||
freshOther := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_fresh_o", Last4: "3333",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
||||
}
|
||||
for _, card := range []*models.Card{&expired, &freshDefault, &freshOther} {
|
||||
if err := db.Create(card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewViewHandler()
|
||||
req := authRequest("GET", "/api/v1/view/init", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetViewInit(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetViewInit status %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Cards []models.Card `json:"cards"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data.Cards) != 2 {
|
||||
t.Fatalf("want 2 visible cards, got %d", len(resp.Data.Cards))
|
||||
}
|
||||
if resp.Data.Cards[0].ID != freshDefault.ID {
|
||||
t.Fatalf("default card should sort first, got id=%d (want %d)", resp.Data.Cards[0].ID, freshDefault.ID)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
@@ -15,18 +20,22 @@ import (
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
|
||||
|
||||
// WebhookHandler handles inbound Stripe webhook events.
|
||||
type WebhookHandler struct {
|
||||
webhookSecret string
|
||||
stripeKey string
|
||||
shippoSecret string
|
||||
}
|
||||
|
||||
// NewWebhookHandler creates a new webhook handler.
|
||||
func NewWebhookHandler(webhookSecret string, stripeKey string) *WebhookHandler {
|
||||
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey}
|
||||
// NewWebhookHandler creates a new webhook handler. Stripe credentials are
|
||||
// wired once at startup via stripe.Key in api/main.go.
|
||||
func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler {
|
||||
return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret}
|
||||
}
|
||||
|
||||
// HandleStripe validates the Stripe signature and routes the event.
|
||||
@@ -44,11 +53,23 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Verify the Stripe signature. Reject anything that doesn't match.
|
||||
if h.webhookSecret == "" {
|
||||
// No secret configured — log a loud warning but still process in dev.
|
||||
log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification")
|
||||
// Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
|
||||
// in production, so the server should never boot into this state; this is
|
||||
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
|
||||
// Slack handlers, which already refuse to run without their secret.
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
|
||||
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
|
||||
return
|
||||
}
|
||||
// Development only; warn loudly but still process so local testing works.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
|
||||
} else {
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
if _, err := webhook.ConstructEvent(body, sig, h.webhookSecret); err != nil {
|
||||
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
|
||||
IgnoreAPIVersionMismatch: true,
|
||||
}); err != nil {
|
||||
webhookLog().Warn("signature verification failed", "error", err)
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil)
|
||||
return
|
||||
}
|
||||
@@ -63,35 +84,35 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
|
||||
switch event.Type {
|
||||
|
||||
case "checkout.session.completed":
|
||||
h.handleCheckoutSessionCompleted(event)
|
||||
h.handleCheckoutSessionCompleted(r.Context(), event)
|
||||
|
||||
// payment_intent.succeeded fires when a charge completes asynchronously
|
||||
// (e.g. 3DS, bank redirects). For confirm=true intents this is normally
|
||||
// already handled by ConvertCart, but the webhook is the authoritative source.
|
||||
case "payment_intent.succeeded":
|
||||
h.handlePaymentIntentSucceeded(event)
|
||||
h.handlePaymentIntentSucceeded(r.Context(), event)
|
||||
|
||||
// payment_intent.payment_failed fires when a payment fails after initial
|
||||
// confirmation (e.g. bank decline on a deferred 3DS flow).
|
||||
case "payment_intent.payment_failed":
|
||||
h.handlePaymentIntentFailed(event)
|
||||
h.handlePaymentIntentFailed(r.Context(), event)
|
||||
|
||||
// Sync local card state when Stripe's side changes.
|
||||
case "payment_method.attached",
|
||||
"payment_method.detached",
|
||||
"payment_method.updated":
|
||||
// Handled by the reconcile job and direct API calls; log only.
|
||||
log.Printf("[webhook] %s received for payment method", event.Type)
|
||||
webhookLog().Info("payment method event received", "event_type", string(event.Type))
|
||||
}
|
||||
|
||||
// Always return 200 so Stripe doesn't retry.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
|
||||
func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event stripe.Event) {
|
||||
var pi stripe.PaymentIntent
|
||||
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
|
||||
log.Printf("[webhook] failed to unmarshal payment_intent: %v", err)
|
||||
webhookLog().Error("failed to unmarshal payment_intent", "error", err, "event", "payment_intent.succeeded")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,10 +122,10 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
var order models.Order
|
||||
if err := db.Where("id = ?", orderIDStr).First(&order).Error; err != nil {
|
||||
log.Printf("[webhook] order %s not found for pi %s", orderIDStr, pi.ID)
|
||||
if err := db.Preload("Items").Where("id = ?", orderIDStr).First(&order).Error; err != nil {
|
||||
webhookLog().Warn("order not found for payment intent", "order_id", orderIDStr, "pi", pi.ID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,8 +134,22 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Defense in depth: even behind a valid signature, refuse to mark an order
|
||||
// paid unless the PaymentIntent's amount and currency match what we charged.
|
||||
// Under ConvertCart these always match (Amount == order.Total at creation), so
|
||||
// a mismatch means a malformed or unexpected event we should not trust.
|
||||
if pi.Amount != int64(order.Total) {
|
||||
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
|
||||
return
|
||||
}
|
||||
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
|
||||
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
|
||||
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
|
||||
return
|
||||
}
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
@@ -125,25 +160,56 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
|
||||
if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil {
|
||||
return nil // Cart already gone.
|
||||
}
|
||||
tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete cart item: %w", err)
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
log.Printf("[webhook] failed to record paid status for order %s (pi=%s): %v", orderIDStr, pi.ID, txErr)
|
||||
webhookLog().Error("failed to record paid status",
|
||||
"order_id", orderIDStr, "pi", pi.ID, "error", txErr)
|
||||
return
|
||||
}
|
||||
|
||||
// A retained (non-ephemeral) card gets its storage TTL refreshed on use.
|
||||
// Ephemeral orders are about to lose their card entirely, so skip the refresh.
|
||||
if order.CardID != 0 && !order.Ephemeral {
|
||||
if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil {
|
||||
webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if order.Ephemeral && pi.PaymentMethod != nil && pi.PaymentMethod.ID != "" {
|
||||
if _, derr := paymentMethodDetachFn(pi.PaymentMethod.ID, nil); derr != nil {
|
||||
webhookLog().Warn("failed to detach ephemeral payment method",
|
||||
"order_id", order.ID, "pi", pi.ID, "error", derr)
|
||||
}
|
||||
}
|
||||
|
||||
// Deferred (3DS) twin of the inline forget-card cleanup in ConvertCart: a
|
||||
// privacy-mode order that selected a saved/QR card carries CardID != 0. Now
|
||||
// that it is paid, hard-delete the local row (Unscoped: Card soft-deletes by
|
||||
// default). Token-ephemeral orders have CardID == 0 and skip this.
|
||||
if order.Ephemeral && order.CardID != 0 {
|
||||
if err := db.Unscoped().Delete(&models.Card{}, order.CardID).Error; err != nil {
|
||||
webhookLog().Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", order.CardID, "error", err)
|
||||
} else if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
|
||||
webhookLog().Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
|
||||
log.Printf("[webhook] order %s marked paid via payment_intent.succeeded (pi=%s)", orderIDStr, pi.ID)
|
||||
go notify.SlackOrderPaid(&order)
|
||||
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) {
|
||||
func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event stripe.Event) {
|
||||
var pi stripe.PaymentIntent
|
||||
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
|
||||
log.Printf("[webhook] failed to unmarshal payment_intent: %v", err)
|
||||
webhookLog().Error("failed to unmarshal payment_intent", "error", err, "event", "payment_intent.payment_failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,21 +218,33 @@ func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
|
||||
Update("status", models.OrderStatusFailed)
|
||||
// authentication_required is recoverable — the cart handler re-confirms the
|
||||
// PI on-session and the customer completes 3DS via redirect. Marking the
|
||||
// order failed here would race with the recovery path and surface a
|
||||
// momentarily-broken state in the TUI.
|
||||
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
|
||||
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
|
||||
"order_id", orderIDStr, "pi", pi.ID)
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
if err := db.Model(&models.Order{}).Where("id = ? AND status IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction).
|
||||
Update("status", models.OrderStatusFailed).Error; err != nil {
|
||||
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
|
||||
}
|
||||
|
||||
errMsg := "unknown"
|
||||
if pi.LastPaymentError != nil {
|
||||
errMsg = pi.LastPaymentError.Msg
|
||||
}
|
||||
log.Printf("[webhook] payment failed for order %s (pi=%s): %s", orderIDStr, pi.ID, errMsg)
|
||||
webhookLog().Warn("payment failed", "order_id", orderIDStr, "pi", pi.ID, "reason", errMsg)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
|
||||
func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, event stripe.Event) {
|
||||
var sess stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
||||
log.Printf("[webhook] failed to unmarshal checkout session: %v", err)
|
||||
webhookLog().Error("failed to unmarshal checkout session", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -175,42 +253,72 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
|
||||
}
|
||||
|
||||
if sess.SetupIntent == nil || sess.Customer == nil {
|
||||
log.Printf("[webhook] checkout session missing setup_intent or customer")
|
||||
webhookLog().Warn("checkout session missing setup_intent or customer")
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
|
||||
if err != nil {
|
||||
log.Printf("[webhook] failed to get setup intent %s: %v", sess.SetupIntent.ID, err)
|
||||
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if si.PaymentMethod == nil {
|
||||
log.Printf("[webhook] setup intent %s has no payment method", si.ID)
|
||||
webhookLog().Warn("setup intent has no payment method", "setup_intent", si.ID)
|
||||
return
|
||||
}
|
||||
|
||||
pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil)
|
||||
if err != nil {
|
||||
log.Printf("[webhook] failed to get payment method %s: %v", si.PaymentMethod.ID, err)
|
||||
webhookLog().Error("failed to get payment method", "payment_method", si.PaymentMethod.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
var user models.User
|
||||
if err := db.Where("stripe_customer_id = ?", sess.Customer.ID).First(&user).Error; err != nil {
|
||||
log.Printf("[webhook] user not found for customer %s: %v", sess.Customer.ID, err)
|
||||
webhookLog().Warn("user not found for customer", "customer", sess.Customer.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotency: skip if this payment method is already saved.
|
||||
// Idempotency: skip if this exact PaymentMethod is already in use
|
||||
var existing models.Card
|
||||
if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// dedup against local cards table, matching last4 + brand + expiry means
|
||||
// the user is re-adding the same physical card. detach the new pm from
|
||||
// stripe and skip the local insert. Local DB is the source of truth
|
||||
// the stripe customers paymentmethods list may contain orphans from before
|
||||
// this fix, which would cause false-positive dedup hits
|
||||
if pm.Card != nil {
|
||||
var dup models.Card
|
||||
err := db.Where(
|
||||
"user_id = ? AND last4 = ? AND brand = ? AND exp_month = ? AND exp_year = ?",
|
||||
user.ID, pm.Card.Last4, string(pm.Card.Brand),
|
||||
int(pm.Card.ExpMonth), int(pm.Card.ExpYear),
|
||||
).First(&dup).Error
|
||||
if err == nil {
|
||||
if _, derr := paymentmethod.Detach(pm.ID, nil); derr != nil {
|
||||
webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
|
||||
}
|
||||
// Bump UpdatedAt so the TUI poll signal can detect that the webhook
|
||||
// fired even though no new row was inserted. storage_expires_at is
|
||||
// reset because the user just re-added the card; this is a card-add
|
||||
// path, not a payment-success refresh, so last_used_at stays as-is.
|
||||
now := time.Now()
|
||||
if uerr := db.Model(&dup).Updates(map[string]any{
|
||||
"updated_at": now,
|
||||
"storage_expires_at": models.CardStorageExpiresAt(now),
|
||||
}).Error; uerr != nil {
|
||||
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
|
||||
}
|
||||
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
card := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: pm.ID,
|
||||
@@ -219,12 +327,178 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
|
||||
ExpMonth: int(pm.Card.ExpMonth),
|
||||
ExpYear: int(pm.Card.ExpYear),
|
||||
}
|
||||
card.InitializeStorageTTL(time.Now())
|
||||
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
log.Printf("[webhook] failed to save card for user %d: %v", user.ID, err)
|
||||
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
audit.CardAdded(user.ID, card.ID, card.Brand, card.Last4)
|
||||
log.Printf("[webhook] card saved via checkout.session.completed for user %d", user.ID)
|
||||
webhookLog().Info("card saved via checkout.session.completed", "user_id", user.ID)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if h.shippoSecret == "" || token != h.shippoSecret {
|
||||
webhookLog().Warn("shippo webhook: token missing or invalid")
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_TOKEN", "token missing or invalid", nil)
|
||||
return
|
||||
}
|
||||
|
||||
const maxBodyBytes = 65536
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "READ_ERROR", "could not read request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Event string `json:"event"`
|
||||
Data struct {
|
||||
TrackingNumber string `json:"tracking_number"`
|
||||
Carrier string `json:"carrier"`
|
||||
TrackingStatus struct {
|
||||
Status string `json:"status"`
|
||||
StatusDetails string `json:"status_details"`
|
||||
StatusDate time.Time `json:"status_date"`
|
||||
} `json:"tracking_status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Event != "track_updated" {
|
||||
webhookLog().Info("shippo webhook: ignoring no-tracking event", "event", payload.Event)
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"skipped": "non-tracking event",
|
||||
"event": payload.Event,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
newStatus, known := mapShippoStatus(payload.Data.TrackingStatus.Status)
|
||||
if !known {
|
||||
webhookLog().Warn("shippo webhook: unrecognized status, skipping",
|
||||
"status", payload.Data.TrackingStatus.Status,
|
||||
"tracking_number", payload.Data.TrackingNumber)
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"skipped": "unrecognized status",
|
||||
"raw_status": payload.Data.TrackingStatus.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
var order models.Order
|
||||
if err := db.Where("tracking_number = ?", payload.Data.TrackingNumber).First(&order).Error; err != nil {
|
||||
webhookLog().Warn("shippo webhook: order not found",
|
||||
"tracking_number", payload.Data.TrackingNumber)
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"skipped": "order not found",
|
||||
"tracking_number": payload.Data.TrackingNumber,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if order.TrackingStatusUpdatedAt != nil && !payload.Data.TrackingStatus.StatusDate.After(*order.TrackingStatusUpdatedAt) {
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"skipped": "stale status_date",
|
||||
"order_id": order.ID,
|
||||
"current_tracking_status": string(order.TrackingStatus),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
statusDate := payload.Data.TrackingStatus.StatusDate
|
||||
previousTrackingStatus := order.TrackingStatus
|
||||
updates := map[string]any{
|
||||
"tracking_status": newStatus,
|
||||
"tracking_status_details": payload.Data.TrackingStatus.StatusDetails,
|
||||
"tracking_status_updated_at": &statusDate,
|
||||
}
|
||||
deliveredTransition := newStatus == models.TrackingStatusDelivered &&
|
||||
order.Status != models.OrderStatusDelivered
|
||||
if deliveredTransition {
|
||||
updates["status"] = models.OrderStatusDelivered
|
||||
}
|
||||
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
webhookLog().Error("shippo webhook: failed to persist tracking update",
|
||||
"order_id", order.ID, "error", err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DB_ERROR", "failed to persist", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if previousTrackingStatus != newStatus {
|
||||
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
||||
go func() {
|
||||
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||
}()
|
||||
}
|
||||
if deliveredTransition {
|
||||
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
||||
}
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"order_id": order.ID,
|
||||
"tracking_status": string(newStatus),
|
||||
"previous_tracking_status": string(previousTrackingStatus),
|
||||
"lifecycle_status": string(order.Status),
|
||||
"delivered_transition": deliveredTransition,
|
||||
"status_details": payload.Data.TrackingStatus.StatusDetails,
|
||||
})
|
||||
}
|
||||
|
||||
func formatTrackingUpdate(status models.TrackingStatus, details string) string {
|
||||
var icon, label string
|
||||
switch status {
|
||||
case models.TrackingStatusPreTransit:
|
||||
icon, label = ":label:", "Label created"
|
||||
case models.TrackingStatusTransit:
|
||||
icon, label = ":truck:", "In transit"
|
||||
case models.TrackingStatusDelivered:
|
||||
icon, label = ":white_check_mark:", "Delivered"
|
||||
case models.TrackingStatusReturned:
|
||||
icon, label = ":leftwards_arrow_with_hook:", "Returned to sender"
|
||||
case models.TrackingStatusFailure:
|
||||
icon, label = ":warning:", "Delivery exception"
|
||||
default:
|
||||
icon, label = ":package:", string(status)
|
||||
}
|
||||
if details != "" {
|
||||
return fmt.Sprintf("%s *%s* — %s", icon, label, details)
|
||||
}
|
||||
return fmt.Sprintf("%s *%s*", icon, label)
|
||||
}
|
||||
|
||||
func mapShippoStatus(s string) (models.TrackingStatus, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "UNKNOWN":
|
||||
return models.TrackingStatusUnknown, true
|
||||
case "PRE_TRANSIT":
|
||||
return models.TrackingStatusPreTransit, true
|
||||
case "TRANSIT":
|
||||
return models.TrackingStatusTransit, true
|
||||
case "DELIVERED":
|
||||
return models.TrackingStatusDelivered, true
|
||||
case "RETURNED":
|
||||
return models.TrackingStatusReturned, true
|
||||
case "FAILURE":
|
||||
return models.TrackingStatusFailure, true
|
||||
}
|
||||
return models.TrackingStatusUnknown, false
|
||||
}
|
||||
|
||||
func writeShippoResponse(w http.ResponseWriter, payload map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
259
api/handlers/webhook_test.go
Normal file
259
api/handlers/webhook_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
|
||||
// a PaymentIntent referencing the given order via metadata. errCode is set on
|
||||
// last_payment_error to drive the authentication_required short-circuit
|
||||
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
|
||||
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
|
||||
t.Helper()
|
||||
pi := map[string]any{
|
||||
"id": piID,
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
|
||||
}
|
||||
if errCode != "" {
|
||||
pi["last_payment_error"] = map[string]any{"code": errCode}
|
||||
}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
return stripe.Event{
|
||||
Type: "payment_intent.payment_failed",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
}
|
||||
|
||||
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
card := models.Card{
|
||||
UserID: userID,
|
||||
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
|
||||
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
order := models.Order{
|
||||
UserID: userID, CardID: card.ID,
|
||||
StripePaymentID: piID,
|
||||
Status: status,
|
||||
Subtotal: 500, Total: 500,
|
||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
||||
ShippingCity: "PDX", ShippingState: "OR",
|
||||
ShippingZip: "97201", ShippingCountry: "US",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
|
||||
// widened predicate (IN (pending, requires_action)) catches a row in the new
|
||||
// requires_action state. Without this case, a card_declined webhook arriving
|
||||
// after respondRequiresAction wrote requires_action would leave the row
|
||||
// stuck forever.
|
||||
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_failed")
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusFailed {
|
||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
|
||||
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
|
||||
// still fires when the row is in requires_action and the error code is
|
||||
// authentication_required. Without the short-circuit, the recovery flow
|
||||
// would race the webhook and surface a momentarily-failed state to the TUI.
|
||||
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_authreq")
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
||||
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
|
||||
// pending -> failed transition against a regression where the widened
|
||||
// predicate accidentally dropped pending from the source set.
|
||||
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusFailed {
|
||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
|
||||
// the predicate: a paid row must NEVER be flipped to failed by a stale
|
||||
// payment_failed webhook. Without this guard, a future maintainer could
|
||||
// widen the IN clause to include paid (e.g. for some refund-retry flow)
|
||||
// and silently break idempotency.
|
||||
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Fatalf("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
|
||||
// short-circuit so the test file exercises every early-return branch in
|
||||
// handlePaymentIntentFailed touched by the predicate work.
|
||||
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
|
||||
testDB, _ := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
pi := map[string]any{"id": "pi_test_no_meta"}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
evt := stripe.Event{
|
||||
Type: "payment_intent.payment_failed",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
h.handlePaymentIntentFailed(context.Background(), evt) // must not panic
|
||||
}
|
||||
|
||||
func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid")
|
||||
|
||||
pi := map[string]any{
|
||||
"id": order.StripePaymentID,
|
||||
"amount": order.Total,
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||
}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
evt := stripe.Event{
|
||||
Type: "payment_intent.succeeded",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.OrderStatusPaid {
|
||||
t.Fatalf("status: want %q, got %q", models.OrderStatusPaid, reloaded.Status)
|
||||
}
|
||||
if reloaded.StripePaymentID != order.StripePaymentID {
|
||||
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
|
||||
|
||||
// PI claims 1 cent; seed order.Total is 500.
|
||||
pi := map[string]any{
|
||||
"id": order.StripePaymentID,
|
||||
"amount": 1,
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||
}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
evt := stripe.Event{
|
||||
Type: "payment_intent.succeeded",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reloaded order: %v", err)
|
||||
}
|
||||
if reloaded.Status == models.OrderStatusPaid {
|
||||
t.Fatalf("order marked paid despite amount mismatch")
|
||||
}
|
||||
}
|
||||
89
api/main.go
89
api/main.go
@@ -11,15 +11,43 @@ import (
|
||||
"time"
|
||||
|
||||
"terminalShop/api/handlers"
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/api/routes"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/observability"
|
||||
"terminalShop/pkg/stripeclient"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
)
|
||||
|
||||
const version = "v0.1.0"
|
||||
|
||||
// everyTick runs fn on its own goroutine every d until ctx is cancelled.
|
||||
// Shared by the reconciler sweeps so the ticker + shutdown plumbing lives in
|
||||
// one place. ReconcileUnshipped intentionally does not use it, it needs a
|
||||
// staggered warm-up run before its first tick.
|
||||
func everyTick(ctx context.Context, d time.Duration, fn func(context.Context)) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(d)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
fn(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func main() {
|
||||
observability.InitLogger()
|
||||
middleware.SetBuildInfo(version)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Load configuration
|
||||
@@ -33,23 +61,44 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
// Run migrations
|
||||
if err := database.Migrate(db); err != nil {
|
||||
log.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
audit.SetDB(db)
|
||||
|
||||
// wire the stripe SDK's package-level key once at startup so concurrent
|
||||
// reconcilers and the request path don't race on stripe.Key writes.
|
||||
// NOTE: assumes single-process — if you ever embed both the SSH and API
|
||||
// binaries together, the last writer wins and one of them silently breaks.
|
||||
stripe.Key = cfg.StripeSecretKey
|
||||
|
||||
// Seed database with initial data
|
||||
if err := database.Seed(db); err != nil {
|
||||
log.Fatalf("Failed to seed database: %v", err)
|
||||
}
|
||||
|
||||
otlpEndpoint := os.Getenv("OTLP_ENDPOINT")
|
||||
if otlpEndpoint == "" {
|
||||
otlpEndpoint = "tempo:4318"
|
||||
}
|
||||
shutdown, err := observability.InitTracing(context.Background(), "terminalshop-api", otlpEndpoint)
|
||||
if err != nil {
|
||||
log.Printf("tracing init failed (non-fatal): %v", err)
|
||||
} else {
|
||||
defer func() { _ = shutdown(context.Background()) }()
|
||||
}
|
||||
|
||||
stripeclient.InitOTel()
|
||||
|
||||
// Init JWT manager (same secret + duration as SSH server)
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
// Setup routes
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.AppURL)
|
||||
router := routes.SetupRoutes(version, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
@@ -76,21 +125,43 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Start reconciliation job. runs every 5 minutes to catch any orders
|
||||
// that were charged but not recorded due to a crash.
|
||||
// Catch orders charged but not recorded due to a crash. Every 5 minutes
|
||||
everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
select {
|
||||
case <-time.After(30 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
handlers.ReconcileUnshipped(ctx)
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileOrders(cfg.StripeSecretKey)
|
||||
handlers.ReconcileUnshipped(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Sweep saved cards whose retention deadline elapsed. Read paths filter
|
||||
// expired rows from query results, so this sweeper only drives the Stripe
|
||||
// detach + audit + physical row delete asynchronously. Hourly.
|
||||
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards)
|
||||
|
||||
// Flip abandoned 3DS orders to failed. Every 15 minutes.
|
||||
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
|
||||
everyTick(ctx, 15*time.Minute, func(ctx context.Context) {
|
||||
handlers.ReconcileStale3DSOrders(ctx, threshold)
|
||||
})
|
||||
|
||||
// Sweep expired pay_redirects rows so the table stays bounded. Lazy delete
|
||||
// on read is the primary path; this only reclaims tokens nobody follows.
|
||||
everyTick(ctx, 10*time.Minute, handlers.ReconcilePayRedirects)
|
||||
|
||||
log.Println("API server started. Press Ctrl+C to shutdown.")
|
||||
|
||||
// Block until signal received
|
||||
@@ -99,10 +170,10 @@ func main() {
|
||||
log.Println("Server is shutting down...")
|
||||
|
||||
// Graceful shutdown with 30 second timeout
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
|
||||
92
api/main_test.go
Normal file
92
api/main_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEveryTick_CallsFnOnTick verifies the helper invokes fn repeatedly on its
|
||||
// interval. A non-blocking send keeps the spawned goroutine from ever blocking
|
||||
// on a full channel, and the 1s timeout per tick is generous vs the 1ms
|
||||
// interval so the test is not timing-flaky.
|
||||
func TestEveryTick_CallsFnOnTick(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
fired := make(chan struct{}, 1)
|
||||
everyTick(ctx, time.Millisecond, func(context.Context) {
|
||||
select {
|
||||
case fired <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
for i := range 3 {
|
||||
select {
|
||||
case <-fired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("tick %d did not fire within 1s", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryTick_StopsOnContextCancel verifies cancelling ctx stops the loop.
|
||||
// After the first tick we cancel inside the inter-tick gap, so everyTick's
|
||||
// select sees only ctx.Done ready (ticker.C is cold) and returns
|
||||
// deterministically — there is no random pick between two ready cases. We then
|
||||
// confirm no further tick arrives within a full interval, which a live loop
|
||||
// could not satisfy. This is the shutdown guarantee the reconcilers depend on.
|
||||
func TestEveryTick_StopsOnContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
const interval = 50 * time.Millisecond
|
||||
ticks := make(chan struct{}) // unbuffered: fn blocks until the test receives
|
||||
everyTick(ctx, interval, func(context.Context) {
|
||||
ticks <- struct{}{}
|
||||
})
|
||||
|
||||
// First tick proves the loop is running and leaves the goroutine back in
|
||||
// select with ticker.C cold for the next ~interval.
|
||||
select {
|
||||
case <-ticks:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("everyTick never called fn")
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
// Cancel landed in the cold gap, so the loop must have taken ctx.Done and
|
||||
// returned. A live loop would tick again at ~interval; a stopped one never
|
||||
// will.
|
||||
select {
|
||||
case <-ticks:
|
||||
t.Fatal("everyTick ticked again after cancel")
|
||||
case <-time.After(interval + 50*time.Millisecond):
|
||||
// good: silent past a full interval — the loop returned
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryTick_PassesContextToFn(t *testing.T) {
|
||||
type ctxKey string
|
||||
const marker ctxKey = "marker"
|
||||
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), marker, "present"))
|
||||
defer cancel()
|
||||
|
||||
got := make(chan string, 1)
|
||||
everyTick(ctx, time.Millisecond, func(c context.Context) {
|
||||
v, _ := c.Value(marker).(string)
|
||||
select {
|
||||
case got <- v:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case v := <-got:
|
||||
if v != "present" {
|
||||
t.Fatalf("fn received wrong context: value = %q, want %q", v, "present")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("fn never ran")
|
||||
}
|
||||
}
|
||||
25
api/middleware/admin.go
Normal file
25
api/middleware/admin.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
// RequireAdmin gates a handler behind X-Admin-Key matching the ADMIN_API_KEY
|
||||
// environment variable. if ADMIN_API_KEY is not set the endpoint returns 503, admin actions
|
||||
// must be explicitly enabled, never be the default option
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := os.Getenv("ADMIN_API_KEY")
|
||||
if expected == "" {
|
||||
utils.RespondError(w, http.StatusServiceUnavailable, "ADMIN_DISABLED", "admin endpoints are disabled", nil)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("X-Admin-Key") != expected {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid admin api key", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
4
api/middleware/doc.go
Normal file
4
api/middleware/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package middleware holds the stuff that runs on every request before a
|
||||
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
|
||||
// recovery net for panics.
|
||||
package middleware
|
||||
@@ -4,16 +4,19 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture status code
|
||||
// responseWriter wraps http.ResponseWriter to capture status code and response size.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
statusCode int
|
||||
bytesWritten int64
|
||||
}
|
||||
|
||||
func newResponseWriter(w http.ResponseWriter) *responseWriter {
|
||||
return &responseWriter{w, http.StatusOK}
|
||||
return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
@@ -21,6 +24,12 @@ func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
n, err := rw.ResponseWriter.Write(b)
|
||||
rw.bytesWritten += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Logger logs HTTP requests
|
||||
func Logger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -29,14 +38,21 @@ func Logger(next http.Handler) http.Handler {
|
||||
wrapped := newResponseWriter(w)
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
sc := trace.SpanContextFromContext(r.Context())
|
||||
tid := ""
|
||||
if sc.HasTraceID() {
|
||||
tid = sc.TraceID().String()
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
log.Printf(
|
||||
"%s %s %d %s",
|
||||
"%s %s %d %s trace_id=%s",
|
||||
r.Method,
|
||||
r.RequestURI,
|
||||
r.URL.Path,
|
||||
wrapped.statusCode,
|
||||
duration,
|
||||
tid,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
215
api/middleware/metrics.go
Normal file
215
api/middleware/metrics.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
httpRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "http_requests_total",
|
||||
Help: "Total HTTP requests processed, labeled by method, route, and status",
|
||||
},
|
||||
[]string{"method", "route", "status"},
|
||||
)
|
||||
|
||||
httpRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_request_duration_seconds",
|
||||
Help: "HTTP request latency in seconds, labeled by method and route",
|
||||
Buckets: []float64{.0001, .0005, .001, .0025, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
|
||||
},
|
||||
[]string{"method", "route"},
|
||||
)
|
||||
|
||||
httpInflightRequests = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "http_inflight_requests",
|
||||
Help: "Number of HTTP requests currently being handled",
|
||||
},
|
||||
)
|
||||
|
||||
httpRequestSize = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_request_size_bytes",
|
||||
Help: "HTTP request body size in bytes",
|
||||
Buckets: prometheus.ExponentialBuckets(64, 4, 8),
|
||||
},
|
||||
[]string{"method", "route"},
|
||||
)
|
||||
|
||||
httpResponseSize = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_response_size_bytes",
|
||||
Help: "HTTP response body size in bytes",
|
||||
Buckets: prometheus.ExponentialBuckets(64, 4, 8),
|
||||
},
|
||||
[]string{"method", "route"},
|
||||
)
|
||||
|
||||
ordersCreatedTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "orders_created_total",
|
||||
Help: "Total orders successfully created, labeled by status",
|
||||
},
|
||||
[]string{"status"},
|
||||
)
|
||||
|
||||
cartConversionTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "cart_conversion_total",
|
||||
Help: "Cart checkout outcomes (success, validation_failed, card_declined, stripe_failed, db_failed, payment_critical)",
|
||||
},
|
||||
[]string{"outcome"},
|
||||
)
|
||||
|
||||
abandoned3DSSweepTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "abandoned_3ds_sweep_total",
|
||||
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
|
||||
},
|
||||
[]string{"outcome"},
|
||||
)
|
||||
|
||||
orderValueDollars = promauto.NewHistogram(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "order_value_dollars",
|
||||
Help: "Distribution of completed order totals, in dollars",
|
||||
Buckets: []float64{1, 5, 10, 20, 30, 50, 75, 100, 150, 250, 500, 1000},
|
||||
},
|
||||
)
|
||||
|
||||
stripeRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "stripe_request_duration_seconds",
|
||||
Help: "Latency of outbound Stripe API calls",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"op", "outcome"},
|
||||
)
|
||||
|
||||
authAttemptsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "auth_attempts_total",
|
||||
Help: "Authentication attempts, by result (success, invalid_secret, missing_fingerprint, user_not_found, registered, error)",
|
||||
},
|
||||
[]string{"result"},
|
||||
)
|
||||
|
||||
rateLimitRejectionsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "rate_limit_rejections_total",
|
||||
Help: "Number of requests rejected by rate limiters",
|
||||
},
|
||||
[]string{"scope"},
|
||||
)
|
||||
|
||||
logMessagesTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "log_messages_total",
|
||||
Help: "Total log lines emitted, labeled by level",
|
||||
},
|
||||
[]string{"level"},
|
||||
)
|
||||
|
||||
buildInfo = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "terminalshop_build_info",
|
||||
Help: "Build metadata; value is always 1, version is on the label",
|
||||
},
|
||||
[]string{"version"},
|
||||
)
|
||||
)
|
||||
|
||||
// Metrics records request count, latency, sizes, and concurrency for every request.
|
||||
func Metrics(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
wrapped := newResponseWriter(w)
|
||||
|
||||
httpInflightRequests.Inc()
|
||||
defer httpInflightRequests.Dec()
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
route := chi.RouteContext(r.Context()).RoutePattern()
|
||||
if route == "" {
|
||||
route = "unknown"
|
||||
}
|
||||
|
||||
duration := time.Since(start).Seconds()
|
||||
status := strconv.Itoa(wrapped.statusCode)
|
||||
|
||||
httpRequestsTotal.WithLabelValues(r.Method, route, status).Inc()
|
||||
httpRequestDuration.WithLabelValues(r.Method, route).Observe(duration)
|
||||
if r.ContentLength > 0 {
|
||||
httpRequestSize.WithLabelValues(r.Method, route).Observe(float64(r.ContentLength))
|
||||
}
|
||||
httpResponseSize.WithLabelValues(r.Method, route).Observe(float64(wrapped.bytesWritten))
|
||||
})
|
||||
}
|
||||
|
||||
// MetricsHandler returns the Prometheus scrape endpoint.
|
||||
func MetricsHandler() http.Handler {
|
||||
return promhttp.Handler()
|
||||
}
|
||||
|
||||
// SetBuildInfo records build metadata as a gauge of value 1.
|
||||
func SetBuildInfo(version string) {
|
||||
buildInfo.WithLabelValues(version).Set(1)
|
||||
}
|
||||
|
||||
// RecordOrderCreated records a successfully created order.
|
||||
func RecordOrderCreated(status string) {
|
||||
ordersCreatedTotal.WithLabelValues(status).Inc()
|
||||
}
|
||||
|
||||
// RecordCartConversion records the outcome of a checkout attempt.
|
||||
func RecordCartConversion(outcome string) {
|
||||
cartConversionTotal.WithLabelValues(outcome).Inc()
|
||||
}
|
||||
|
||||
// ObserveOrderValueCents records the value of a completed order (cents → dollars).
|
||||
func ObserveOrderValueCents(cents int) {
|
||||
orderValueDollars.Observe(float64(cents) / 100.0)
|
||||
}
|
||||
|
||||
// RecordAbandoned3DSSweep records the outcome of one row processed by
|
||||
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
|
||||
// Help string.
|
||||
func RecordAbandoned3DSSweep(outcome string) {
|
||||
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
|
||||
}
|
||||
|
||||
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
|
||||
// per-label values via prometheus testutil. Not used in production code.
|
||||
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
|
||||
return abandoned3DSSweepTotal
|
||||
}
|
||||
|
||||
// ObserveStripeRequest records latency of a Stripe API call.
|
||||
func ObserveStripeRequest(op, outcome string, durSec float64) {
|
||||
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)
|
||||
}
|
||||
|
||||
// RecordAuthAttempt records an authentication attempt outcome.
|
||||
func RecordAuthAttempt(result string) {
|
||||
authAttemptsTotal.WithLabelValues(result).Inc()
|
||||
}
|
||||
|
||||
// RecordRateLimitRejection records a request blocked by a rate limiter.
|
||||
func RecordRateLimitRejection(scope string) {
|
||||
rateLimitRejectionsTotal.WithLabelValues(scope).Inc()
|
||||
}
|
||||
|
||||
// RecordLogLevel increments the per-level log counter.
|
||||
func RecordLogLevel(level string) {
|
||||
logMessagesTotal.WithLabelValues(level).Inc()
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/httprate"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
@@ -14,6 +16,7 @@ func RateLimitByIP(limit int, window time.Duration) func(http.Handler) http.Hand
|
||||
limit, window,
|
||||
httprate.WithKeyFuncs(httprate.KeyByIP),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
RecordRateLimitRejection("ip")
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
|
||||
}),
|
||||
)
|
||||
@@ -29,9 +32,10 @@ func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Ha
|
||||
// Fall back to IP if no user context (shouldn't happen on protected routes).
|
||||
return httprate.KeyByIP(r)
|
||||
}
|
||||
return http.CanonicalHeaderKey("user") + ":" + string(rune(userID)), nil
|
||||
return "user:" + strconv.FormatUint(uint64(userID), 10), nil
|
||||
}),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
RecordRateLimitRejection("user")
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
@@ -13,7 +13,12 @@ func Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("PANIC: %v\n%s", err, debug.Stack())
|
||||
slog.Error("panic recovered",
|
||||
"error", err,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
|
||||
utils.RespondError(
|
||||
w,
|
||||
|
||||
3
api/routes/doc.go
Normal file
3
api/routes/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package routes is the switchboard: it maps every URL to the handler that
|
||||
// knows what to do with it.
|
||||
package routes
|
||||
@@ -9,26 +9,35 @@ import (
|
||||
"terminalShop/api/handlers"
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/auth"
|
||||
|
||||
"github.com/riandyrn/otelchi"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all API routes
|
||||
func SetupRoutes(
|
||||
version string,
|
||||
stripeSecretKey string,
|
||||
stripeWebhookSecret string,
|
||||
jwtManager *auth.JWTManager,
|
||||
authFingerprintKey string,
|
||||
shippoAPIKey string,
|
||||
bringAPIUID string,
|
||||
bringAPIKey string,
|
||||
bringCustomerNumber string,
|
||||
shippoWebhookSecret string,
|
||||
appURL string,
|
||||
slackSigningSecret string,
|
||||
adminAPIKey string,
|
||||
apiPort string,
|
||||
maxOrderCents int,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(otelchi.Middleware("terminalshop-api", otelchi.WithChiRoutes(r)))
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Recovery)
|
||||
r.Use(middleware.Metrics)
|
||||
r.Use(middleware.Auth(jwtManager))
|
||||
// Broad IP-level rate limit: 200 req/min per IP across all endpoints.
|
||||
r.Use(middleware.RateLimitByIP(200, time.Minute))
|
||||
@@ -37,29 +46,47 @@ func SetupRoutes(
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
productHandler := handlers.NewProductHandler()
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey)
|
||||
cartHandler := handlers.NewCartHandler(appURL, maxOrderCents)
|
||||
cardHandler := handlers.NewCardHandler(appURL)
|
||||
orderHandler := handlers.NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||
viewHandler := handlers.NewViewHandler()
|
||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey)
|
||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
|
||||
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
|
||||
adminUserHandler := handlers.NewAdminUserHandler()
|
||||
accountHandler := handlers.NewAccountHandler(maxOrderCents)
|
||||
|
||||
// Short payment redirect and success page — no auth required.
|
||||
r.Get("/pay/{token}", handlers.PayRedirect)
|
||||
r.Get("/t/{id}", handlers.TrackingRedirect)
|
||||
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
|
||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
|
||||
})
|
||||
r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
|
||||
})
|
||||
r.Handle("/metrics", middleware.MetricsHandler())
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Health
|
||||
// Health — accept HEAD for cheap probes from monitoring tools.
|
||||
r.Get("/health", healthHandler.GetHealth)
|
||||
r.Head("/health", healthHandler.GetHealth)
|
||||
r.Get("/ping", healthHandler.Ping)
|
||||
r.Head("/ping", healthHandler.Ping)
|
||||
|
||||
// Stripe webhooks — unauthenticated, verified via HMAC signature.
|
||||
// Apply a generous IP rate limit here (Stripe retries from many IPs).
|
||||
r.With(middleware.RateLimitByIP(60, time.Minute)).
|
||||
Post("/webhooks/stripe", webhookHandler.HandleStripe)
|
||||
r.With(middleware.RateLimitByIP(60, time.Minute)).
|
||||
Post("/webhooks/shippo", webhookHandler.HandleShippo)
|
||||
|
||||
// Slack interactivity — unauthenticated at the HTTP layer; security
|
||||
// comes from HMAC signature verification inside the handler.
|
||||
r.With(middleware.RateLimitByIP(60, time.Minute)).
|
||||
Post("/slack/interactivity", slackHandler.HandleInteractivity)
|
||||
|
||||
// Auth — stricter IP rate limit to protect against brute-force.
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -79,6 +106,12 @@ func SetupRoutes(
|
||||
|
||||
r.Get("/view/init", viewHandler.GetViewInit)
|
||||
|
||||
// Account self-service settings, JWT-authed, edits the callers
|
||||
// own row (user id from context, never a URL param). Distinct from
|
||||
// the admin /users/{id}/order-cap setter in the RequireAdmin group.
|
||||
r.Put("/account/spend-limit", accountHandler.SetSpendLimit)
|
||||
r.Put("/account/privacy-mode", accountHandler.SetPrivacyMode)
|
||||
|
||||
// Cart
|
||||
r.Route("/cart", func(r chi.Router) {
|
||||
r.Get("/", cartHandler.GetCart)
|
||||
@@ -105,6 +138,9 @@ func SetupRoutes(
|
||||
|
||||
// Orders
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
|
||||
r.With(middleware.RateLimitByUser(10, time.Minute)).Post("/orders/{id}/retry-auth", cartHandler.RetryAuth)
|
||||
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||
|
||||
// Addresses
|
||||
@@ -115,6 +151,15 @@ func SetupRoutes(
|
||||
r.Put("/{id}/default", addressHandler.SetDefaultAddress)
|
||||
})
|
||||
})
|
||||
|
||||
// Admin endpoints, gated by ADMIN_API_KEY only, no JWT required
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAdmin)
|
||||
r.Patch("/orders/{id}/tracking", orderHandler.UpdateTracking)
|
||||
r.Post("/orders/{id}/label", orderHandler.PurchaseLabel)
|
||||
// Set or clear a user's per-user order spend-cap override.
|
||||
r.Patch("/users/{id}/order-cap", adminUserHandler.SetUserOrderCap)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
3
cmd/reconcile-cards/doc.go
Normal file
3
cmd/reconcile-cards/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
|
||||
// about and detaches them. Run it with -dry-run first if you're nervous.
|
||||
package main
|
||||
73
cmd/reconcile-cards/main.go
Normal file
73
cmd/reconcile-cards/main.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"terminalShop/pkg/config"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "list orphans without detaching")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
if _, err := database.Connect(cfg.DatabaseURL); err != nil {
|
||||
log.Fatalf("db: %v", err)
|
||||
}
|
||||
db := database.GetDB()
|
||||
stripe.Key = cfg.StripeSecretKey
|
||||
|
||||
var users []models.User
|
||||
if err := db.Where("stripe_customer_id != ''").Find(&users).Error; err != nil {
|
||||
log.Fatalf("list users: %v", err)
|
||||
}
|
||||
|
||||
totalOrphans, totalDetached := 0, 0
|
||||
for _, user := range users {
|
||||
var activeCards []models.Card
|
||||
if err := db.Where("user_id = ?", user.ID).Find(&activeCards).Error; err != nil {
|
||||
log.Printf("user %d: list cards: %v", user.ID, err)
|
||||
continue
|
||||
}
|
||||
keep := make(map[string]bool, len(activeCards))
|
||||
for _, card := range activeCards {
|
||||
keep[card.StripePaymentID] = true
|
||||
}
|
||||
iter := paymentmethod.List(&stripe.PaymentMethodListParams{
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
Type: stripe.String("card"),
|
||||
})
|
||||
for iter.Next() {
|
||||
pm := iter.PaymentMethod()
|
||||
if keep[pm.ID] {
|
||||
continue
|
||||
}
|
||||
totalOrphans++
|
||||
last4 := ""
|
||||
if pm.Card != nil {
|
||||
last4 = pm.Card.Last4
|
||||
}
|
||||
log.Printf("orphan: user=%d cus=%s pm=%s last4=%s", user.ID, user.StripeCustomerID, pm.ID, last4)
|
||||
if *dryRun {
|
||||
continue
|
||||
}
|
||||
if _, err := paymentmethod.Detach(pm.ID, nil); err != nil {
|
||||
log.Printf(" detach failed: %v", err)
|
||||
continue
|
||||
}
|
||||
totalDetached++
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
log.Printf("user %d: list pms: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
log.Printf("done. orphans=%d detached=%d dry_run=%v", totalOrphans, totalDetached, *dryRun)
|
||||
}
|
||||
25
cog.toml
Normal file
25
cog.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
from_latest_tag = false
|
||||
ignore_merge_commits = false
|
||||
ignore_fixup_commits = false
|
||||
disable_changelog = false
|
||||
disable_bump_commit = false
|
||||
generate_mono_repository_global_tag = true
|
||||
generate_mono_repository_package_tags = true
|
||||
branch_whitelist = []
|
||||
pre = "alpha.*"
|
||||
skip_ci = "[skip ci]"
|
||||
skip_untracked = false
|
||||
pre_bump_hooks = []
|
||||
post_bump_hooks = []
|
||||
pre_package_bump_hooks = []
|
||||
post_package_bump_hooks = []
|
||||
|
||||
[git_hooks]
|
||||
|
||||
[commit_types]
|
||||
|
||||
[changelog]
|
||||
path = "CHANGELOG.md"
|
||||
authors = []
|
||||
|
||||
[bump_profiles]
|
||||
@@ -1,83 +0,0 @@
|
||||
# Checkout FLow
|
||||
|
||||
## The Problemo
|
||||
|
||||
Charging a card and recording the order are two different operations accross
|
||||
two seperate systems (Stripe and the database). If anything fails betweenj
|
||||
them, you can end up in a state where a customer was charged but no
|
||||
order exists in the database.
|
||||
|
||||
## How It Works
|
||||
|
||||
The ´ConvertCart´ handler (´api/handlers/cart.go´) avoids this by following
|
||||
a specific order of operations:
|
||||
|
||||
1. **Create the order first** written to the DB with ´status = "pending"´
|
||||
and no Stripe ID yet. This means there is always a DB record before any
|
||||
money moves.
|
||||
|
||||
+2. **Charge Stripe** — using an idempotency key of `order-{id}`. If the
|
||||
request is retried for any reason, Stripe will return the original result
|
||||
instead of charging twice.
|
||||
|
||||
+3. **On charge failure** — the order is marked `status = "failed"` and the
|
||||
error is returned to the client. The cart is left untouched.
|
||||
|
||||
+4. **On charge success** — a single DB transaction atomically:
|
||||
- Sets `status = "paid"` and writes the Stripe PaymentIntent ID
|
||||
- Deletes the cart items
|
||||
- Clears the address and card from the cart
|
||||
|
||||
+5. **If the transaction fails after a successful charge** — this is the
|
||||
worst case. The charge went through but the DB update failed. A
|
||||
`[CRITICAL]` log line is emitted with the order ID and PaymentIntent ID
|
||||
for manual reconciliation. See below.
|
||||
|
||||
## Reconciliation
|
||||
|
||||
If a `[CRITICAL]` log appears, find the order and patch it manually:
|
||||
|
||||
```sql
|
||||
UPDATE orders
|
||||
SET status = 'paid', stripe_payment_id = '<pi_id_from_log>'
|
||||
WHERE id = <order_id_from_log>;
|
||||
```
|
||||
|
||||
Then verify the PaymentIntent status in the Stripe dashboard matches.
|
||||
|
||||
A pending order with no ´stripe_payment_id´ after more than a few minutes
|
||||
indicates a failed charge (status should be ´failed´). A pending order
|
||||
with a ´stripe_payment_id´ indicates a reconciliation gap.
|
||||
|
||||
## Testing
|
||||
|
||||
Use Stripe´s test mode with the following card numbers (any future expiry,
|
||||
any CVC, any ZIP):
|
||||
|
||||
the folling table is constructed by ["Card number"]["Behaviour"]
|
||||
|
||||
4242 4242 4242 4242 - Success
|
||||
4000 0000 0000 0002 - Generic decline
|
||||
4000 0000 0000 9995 - Insufficient funds
|
||||
4000 0000 0000 0069 - Expired card
|
||||
4000 0000 0000 0127 - Incorrect CVC
|
||||
|
||||
### What to verify on success
|
||||
- Response is ´200´ with an order object
|
||||
- Order in DB has ´status = "paid"´ and a non-empty ´stripe_payment_id´
|
||||
- Cart items for that user are deleted
|
||||
- Cart ´address_id´ and ´card_id´ are cleared
|
||||
|
||||
### What to verify on decline
|
||||
- Response is `402` with `CARD_ERROR`
|
||||
- Order in DB has `status = "failed"`
|
||||
- Cart items are untouched
|
||||
|
||||
## Known Limitations
|
||||
|
||||
A server crash (not an error — a literal process kill) between the Stripe
|
||||
charge succeeding and the DB transaction committing will leave the order as
|
||||
`pending` with no `stripe_payment_id`. The `[CRITICAL]` log won't fire
|
||||
because the process is dead. The fix is a reconciliation job that
|
||||
periodically queries Stripe for PaymentIntents and cross-checks them
|
||||
against orders — not implemented yet.
|
||||
@@ -1,342 +0,0 @@
|
||||
# Deployment Plan: Docker + VPS + Domain
|
||||
|
||||
**Goal:** Get the app running publicly so anyone can connect with `ssh yourdomain.com`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Reference uses AWS ECS + SST (overkill). We use:
|
||||
|
||||
- **Hetzner VPS** (~€4/month) — Linux server with a real public IP
|
||||
- **Docker Compose** — runs all services, replaces `dev.sh`/Procfile
|
||||
- **Caddy** — reverse proxy for the API, handles HTTPS automatically (free Let's Encrypt)
|
||||
- **Cloudflare** — DNS (free tier, fast propagation)
|
||||
- **Your domain** — e.g. `yourcoffee.shop`
|
||||
|
||||
Port layout on the VPS:
|
||||
- `:22` → SSH TUI server (users connect here)
|
||||
- `:443` → API (Caddy terminates TLS, proxies to API container on :8080)
|
||||
- `:80` → Caddy (redirects to HTTPS)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Prerequisites
|
||||
|
||||
### 1a. Buy a domain
|
||||
- Recommended: Cloudflare Registrar (cheapest, DNS included, no upsells)
|
||||
- Or: Namecheap, then point nameservers to Cloudflare
|
||||
|
||||
### 1b. Get a VPS
|
||||
- **Hetzner Cloud** → Create account → New Server
|
||||
- Location: closest to you (Falkenstein/Helsinki for EU)
|
||||
- OS: Ubuntu 24.04
|
||||
- Type: CX22 (2 vCPU, 4GB RAM, €3.79/month) — plenty for this
|
||||
- Add your SSH public key during setup so you can SSH in
|
||||
- Note the public IP address
|
||||
|
||||
### 1c. DNS (Cloudflare)
|
||||
Add these A records pointing to your VPS IP:
|
||||
|
||||
| Type | Name | Value |
|
||||
|------|------|-------|
|
||||
| A | `@` | `<VPS IP>` |
|
||||
| A | `www` | `<VPS IP>` |
|
||||
| A | `api` | `<VPS IP>` |
|
||||
|
||||
Set proxy status to **DNS only** (grey cloud) — not proxied. Cloudflare proxying breaks raw TCP (SSH).
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Files to Create
|
||||
|
||||
### `Dockerfile.ssh`
|
||||
|
||||
Directly based on reference's `packages/go/Dockerfile`. Two-stage build: compile in Go Alpine, run in minimal Alpine.
|
||||
|
||||
The reference uses `CGO_ENABLED=0` but our SQLite driver needs CGO. Fix: switch to the pure-Go SQLite driver (`modernc.org/sqlite`) so we can keep `CGO_ENABLED=0` and get tiny images.
|
||||
|
||||
```dockerfile
|
||||
FROM golang:alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o ssh ./main.go
|
||||
|
||||
FROM alpine:3.21
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /app/ssh .
|
||||
CMD TERM=xterm-256color ./ssh
|
||||
```
|
||||
|
||||
### `Dockerfile.api`
|
||||
|
||||
```dockerfile
|
||||
FROM golang:alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o api ./api/main.go
|
||||
|
||||
FROM alpine:3.21
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /app/api .
|
||||
CMD ["./api"]
|
||||
```
|
||||
|
||||
### `docker-compose.prod.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
restart: unless-stopped
|
||||
env_file: .env.prod
|
||||
volumes:
|
||||
- ./data:/data
|
||||
expose:
|
||||
- "8080"
|
||||
|
||||
ssh:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.ssh
|
||||
restart: unless-stopped
|
||||
env_file: .env.prod
|
||||
ports:
|
||||
- "22:22"
|
||||
volumes:
|
||||
- ./.ssh:/root/.ssh
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
```
|
||||
|
||||
### `Caddyfile`
|
||||
|
||||
```
|
||||
api.yourdomain.com {
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
```
|
||||
|
||||
Caddy automatically gets a Let's Encrypt cert for the domain. No manual SSL setup.
|
||||
|
||||
### `.env.prod`
|
||||
|
||||
```
|
||||
# API
|
||||
API_PORT=8080
|
||||
DATABASE_URL=/data/terminalshop.db
|
||||
JWT_SECRET=<generate with: openssl rand -hex 32>
|
||||
AUTH_FINGERPRINT_KEY=<generate with: openssl rand -hex 32>
|
||||
ENVIRONMENT=production
|
||||
APP_URL=https://api.yourdomain.com
|
||||
|
||||
# Stripe (live keys in production)
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
STRIPE_PUBLIC_KEY=pk_live_...
|
||||
STRIPE_WEBHOOK_SECRET=<from stripe dashboard>
|
||||
|
||||
# Shipping
|
||||
SHIPPO_API_KEY=...
|
||||
BRING_API_UID=...
|
||||
BRING_API_KEY=...
|
||||
|
||||
# SSH server
|
||||
API_URL=http://api:8080
|
||||
```
|
||||
|
||||
Note: `API_URL` uses `api` (the Docker Compose service name) not `localhost` — containers talk to each other by service name.
|
||||
|
||||
### `.dockerignore`
|
||||
|
||||
```
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
terminal-shop-source/
|
||||
*.db
|
||||
.ssh/
|
||||
docs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Code Changes
|
||||
|
||||
### 3a. SSH server host binding
|
||||
|
||||
`main.go` currently listens on `localhost`. In Docker it must listen on `0.0.0.0` to accept connections from outside the container.
|
||||
|
||||
```go
|
||||
// Change:
|
||||
host = "localhost"
|
||||
port = "23456"
|
||||
|
||||
// To (read from env):
|
||||
host = "0.0.0.0"
|
||||
port = os.Getenv("SSH_PORT") // default "22" in prod, "23456" in dev
|
||||
```
|
||||
|
||||
Or just hardcode `0.0.0.0` and keep port as env var.
|
||||
|
||||
### 3b. SSH host key path
|
||||
|
||||
Currently hardcoded to `.ssh/term_info_ed25519`. In Docker this needs to be an absolute path or a path relative to the working directory that gets volume-mounted. The volume mount in `docker-compose.prod.yml` handles this — generate the key once on the server and it persists.
|
||||
|
||||
Generate on the VPS before first run:
|
||||
```bash
|
||||
mkdir -p .ssh
|
||||
ssh-keygen -t ed25519 -f .ssh/term_info_ed25519 -N ""
|
||||
```
|
||||
|
||||
### 3c. Switch SQLite driver to pure Go
|
||||
|
||||
In `pkg/database/db.go`, swap the import:
|
||||
|
||||
```go
|
||||
// Remove:
|
||||
_ "github.com/mattn/go-sqlite3" // or however it's imported
|
||||
|
||||
// Add:
|
||||
_ "modernc.org/sqlite"
|
||||
```
|
||||
|
||||
And update go.mod:
|
||||
```bash
|
||||
go get modernc.org/sqlite
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
This lets us use `CGO_ENABLED=0` in both Dockerfiles, keeping images small and the build simple.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: VPS Setup
|
||||
|
||||
SSH into your new server:
|
||||
|
||||
```bash
|
||||
ssh root@<VPS IP>
|
||||
```
|
||||
|
||||
Install Docker:
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
```
|
||||
|
||||
Clone your repo:
|
||||
```bash
|
||||
git clone https://github.com/yourusername/terminalShop.git
|
||||
cd terminalShop
|
||||
```
|
||||
|
||||
Create data directory and generate SSH host key:
|
||||
```bash
|
||||
mkdir -p data .ssh
|
||||
ssh-keygen -t ed25519 -f .ssh/term_info_ed25519 -N ""
|
||||
```
|
||||
|
||||
Create `.env.prod` with your production secrets (copy template above, fill in real values).
|
||||
|
||||
Open firewall:
|
||||
```bash
|
||||
ufw allow 22/tcp
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw enable
|
||||
```
|
||||
|
||||
**Important:** Port 22 is now used by your app. Change admin SSH to a different port first (e.g. 2222) so you don't lock yourself out:
|
||||
|
||||
```bash
|
||||
# In /etc/ssh/sshd_config, change:
|
||||
Port 2222
|
||||
|
||||
systemctl restart sshd
|
||||
# Open new terminal, verify: ssh root@<VPS IP> -p 2222
|
||||
# Then: ufw allow 2222/tcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: First Deploy
|
||||
|
||||
On the VPS:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
Test:
|
||||
```bash
|
||||
ssh yourdomain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Stripe Webhook
|
||||
|
||||
In Stripe Dashboard → Developers → Webhooks:
|
||||
- Add endpoint: `https://api.yourdomain.com/api/v1/webhooks/stripe`
|
||||
- Copy the signing secret into `.env.prod` as `STRIPE_WEBHOOK_SECRET`
|
||||
- No more ngrok needed in production
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Ongoing Deploys
|
||||
|
||||
When you push changes:
|
||||
```bash
|
||||
# On VPS:
|
||||
git pull
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
Or automate with a GitHub Action that SSHes into the VPS and runs this on every push to main.
|
||||
|
||||
---
|
||||
|
||||
## Dev vs Prod Summary
|
||||
|
||||
| | Dev | Prod |
|
||||
|--|-----|------|
|
||||
| Process manager | Goreman + Procfile | Docker Compose |
|
||||
| SSH port | 23456 | 22 |
|
||||
| API URL | localhost:8080 | api.yourdomain.com (HTTPS) |
|
||||
| Database | `terminalshop.db` (local) | `/data/terminalshop.db` (volume) |
|
||||
| Stripe | ngrok + test keys | real domain + live keys |
|
||||
| Webhooks | stripe CLI listener | Stripe dashboard endpoint |
|
||||
|
||||
---
|
||||
|
||||
## TODO After Deployment
|
||||
|
||||
- [ ] Switch from SQLite to PostgreSQL (add `postgres` service to compose, update connection string)
|
||||
- [ ] Set up GitHub Actions for automatic deploy on push to main
|
||||
- [ ] Add health check endpoint to Docker Compose so containers restart if app hangs
|
||||
- [ ] Structured logging (write to file + stdout in prod)
|
||||
@@ -1,93 +0,0 @@
|
||||
# Gap Analysis: Terminal Coffee Shop vs Reference (terminal.shop)
|
||||
|
||||
**Date:** 2026-04-08
|
||||
|
||||
---
|
||||
|
||||
## What's Already Working
|
||||
|
||||
- SSH server with key-based auth + auto user creation
|
||||
- Shop page with live detail panel
|
||||
- Cart (add/remove, synced to API)
|
||||
- Full checkout flow: shipping → payment → confirmation
|
||||
- Saved addresses and saved cards
|
||||
- Order history in account view
|
||||
- FAQ page
|
||||
- About page
|
||||
- Splash screen with blinking cursor animation
|
||||
- Responsive layout (small/medium/large)
|
||||
- Token refresh
|
||||
- Full backend API (Chi router, PostgreSQL, GORM, Stripe webhooks)
|
||||
- Shippo shipping integration
|
||||
|
||||
---
|
||||
|
||||
## Missing Features (vs reference)
|
||||
|
||||
### 1. API Token Management
|
||||
|
||||
- **Reference:** Create, view, and revoke personal API tokens
|
||||
- **Current:** Not implemented
|
||||
- **Reference file:** `tokens.go`
|
||||
- **Task:** Add tokens section under account view with list + create/revoke actions
|
||||
|
||||
### 2. OAuth App Management
|
||||
|
||||
- **Reference:** Create, edit, delete OAuth apps with redirect URIs; uses `huh` form
|
||||
- **Current:** Not implemented
|
||||
- **Reference file:** `apps.go`
|
||||
- **Task:** Add apps section under account view with form-based CRUD
|
||||
|
||||
### 3. Keyboard Interactive SSH Auth (Anonymous Sessions)
|
||||
|
||||
- **Reference:** Supports keyboard interactive auth in addition to public key, generates UUID fingerprints for anonymous users
|
||||
- **Current:** Public key auth only (`WithPublicKeyAuth` in `main.go`)
|
||||
- **Task:** Add `WithKeyboardInteractiveAuth` to Wish options; generate UUID fingerprint for anonymous sessions
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Issues
|
||||
|
||||
### Theme System
|
||||
|
||||
- **Reference:** Centralized `theme.Theme` struct with methods (`Base()`, `TextAccent()`, `Border()`, `Brand()`, `Accent()`)
|
||||
- **Current:** 40+ hardcoded hex colors scattered across view files (e.g. `#4682B4`, `#FFFFFF`, `#666666`)
|
||||
- **Task:** Create `pkg/tui/theme/theme.go`, extract all colors and styles, update all view files to reference theme
|
||||
|
||||
### State Management
|
||||
|
||||
- **Reference:** Nested state substruct: `state.shop`, `state.cart`, `state.account`, etc. — each feature owns its state
|
||||
- **Current:** Flat `Model` struct with 80+ fields, all mixed together
|
||||
- **Task:** Refactor into nested substructs per feature area (breaking change — do after features are stable)
|
||||
|
||||
### Viewport-Based Scrolling
|
||||
|
||||
- **Reference:** Uses `charmbracelet/bubbles/viewport` throughout for scrollable content
|
||||
- **Current:** Manual scroll offset math with hardcoded heights (e.g. `-9` for cart viewport height)
|
||||
- **Task:** Replace manual scroll math with `viewport.Model` in cart, account, and FAQ views
|
||||
|
||||
---
|
||||
|
||||
## Architecture Comparison
|
||||
|
||||
| Area | Current | Reference |
|
||||
|------|---------|-----------|
|
||||
| State management | Flat `Model` struct | Nested `state` substructs per feature |
|
||||
| Theme/styling | 40+ hardcoded colors | Centralized `theme.Theme` |
|
||||
| Scrolling | Manual offset math | `viewport.Model` |
|
||||
| Backend | Full custom API (Chi + GORM) | External SDK (`terminal-sdk-go`) |
|
||||
| Auth | SSH pubkey + JWT | SSH pubkey + keyboard interactive |
|
||||
| Product model | Custom `Coffee` struct | SDK `terminal.Product` with variants |
|
||||
| Forms | `huh` (partial) | `huh` (comprehensive, with huh theme) |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Order of Work
|
||||
|
||||
1. **Fix known bugs** — address delete bug and placeholder item (quick wins, low risk)
|
||||
2. **Theme system** — extract colors; unblocks all visual polish
|
||||
3. **Viewport-based scrolling** — replace manual math, fixes responsiveness
|
||||
4. **API token management** — useful feature, account section
|
||||
5. **OAuth app management** — lowest priority unless API access is a goal
|
||||
6. **Keyboard interactive SSH auth** — enables anonymous browsing
|
||||
7. **Nested state structs** — last, after features are stable (pure refactor)
|
||||
@@ -1,80 +0,0 @@
|
||||
# Custom Card Form with Stripe.js
|
||||
|
||||
## Why
|
||||
|
||||
The reference (terminal.shop) uses their own branded card input page with Stripe.js
|
||||
instead of Stripe Hosted Checkout. This means:
|
||||
|
||||
- Full control over the UI/branding
|
||||
- No webhooks needed for card saving (token POSTed directly to API)
|
||||
- No Stripe Checkout Session required
|
||||
- Cleaner flow, fewer moving parts
|
||||
|
||||
## How it would work
|
||||
|
||||
1. TUI calls `POST /api/v1/cards/collect` → gets `https://yourdomain.com/pay/TOKEN`
|
||||
2. User opens URL → sees your own card form (HTML + Stripe.js)
|
||||
3. Stripe.js tokenizes card in the browser (client-side, no raw card data APIs needed)
|
||||
4. Page POSTs token + TOKEN to your API: `POST /api/v1/cards/collect/TOKEN`
|
||||
5. API saves the card to the database
|
||||
6. TUI polling detects new card and auto-proceeds
|
||||
|
||||
## What to build
|
||||
|
||||
### 1. HTML page served at `GET /pay/{token}`
|
||||
|
||||
Instead of redirecting to Stripe, serve an HTML page with Stripe Elements.
|
||||
The TOKEN from the URL is used to associate the card with the right user.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
</head>
|
||||
<body>
|
||||
<form id="card-form">
|
||||
<div id="card-element"></div>
|
||||
<button type="submit">Save Card</button>
|
||||
</form>
|
||||
<script>
|
||||
const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');
|
||||
const elements = stripe.elements();
|
||||
const card = elements.create('card');
|
||||
card.mount('#card-element');
|
||||
|
||||
document.getElementById('card-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const { token } = await stripe.createToken(card);
|
||||
await fetch('/api/v1/cards/collect/TOKEN_FROM_URL', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: token.id })
|
||||
});
|
||||
window.location.href = '/card-added';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
### 2. New API endpoint: `POST /api/v1/cards/collect/{token}`
|
||||
|
||||
- Looks up the user from the token (stored in DB or memory map)
|
||||
- Calls Stripe to convert token → PaymentMethod
|
||||
- Attaches to customer
|
||||
- Saves card to DB
|
||||
- No webhook needed
|
||||
|
||||
### 3. Remove Stripe Checkout Session logic from CollectCard
|
||||
|
||||
`CollectCard` just generates a token, stores user association, returns the URL.
|
||||
No `stripeSession.New()` call needed.
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires a publicly accessible HTTPS domain (same as current approach)
|
||||
- The in-memory token→userID map works fine (same TTL concerns as current approach)
|
||||
- Could remove the `checkout.session.completed` webhook handler entirely
|
||||
- Would need to remove the Stripe Checkout Session dependency from `cards.go`
|
||||
@@ -1,225 +0,0 @@
|
||||
# Theme System Implementation Reference
|
||||
|
||||
> Use this document when implementing or modifying the theme system.
|
||||
> Every decision and pattern is explained so context loss doesn't stall progress.
|
||||
|
||||
---
|
||||
|
||||
## What We're Doing
|
||||
|
||||
Replacing 40+ scattered `lipgloss.Color("#XXXXXX")` calls across 13 TUI files with a
|
||||
centralized `Theme` struct in `pkg/tui/theme/`. Copied closely from the reference
|
||||
implementation in `terminal-shop-source/packages/go/pkg/tui/theme/`.
|
||||
|
||||
---
|
||||
|
||||
## New Files to Create
|
||||
|
||||
```
|
||||
pkg/tui/theme/theme.go — Theme struct, BasicTheme(), all color/style methods
|
||||
pkg/tui/theme/huh.go — HuhTheme(), copyFieldStyles(), copyTextStyles()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify (in order)
|
||||
|
||||
| Step | File | Type of change |
|
||||
|------|------|----------------|
|
||||
| 1 | `pkg/tui/theme/theme.go` | Create |
|
||||
| 2 | `pkg/tui/theme/huh.go` | Create |
|
||||
| 3 | `pkg/tui/model.go` | Add `renderer` + `theme` fields; update constructors |
|
||||
| 4 | `main.go` | Create renderer from SSH session; pass to NewModelWithAuth |
|
||||
| 5 | `pkg/tui/views.go` | 3 color swaps |
|
||||
| 6 | `pkg/tui/splash.go` | 2 color swaps |
|
||||
| 7 | `pkg/tui/header.go` | 4 color swaps |
|
||||
| 8 | `pkg/tui/footer.go` | 2 color swaps |
|
||||
| 9 | `pkg/tui/breadcrumbs.go` | 3 color swaps |
|
||||
| 10 | `pkg/tui/shop.go` | 6 color swaps |
|
||||
| 11 | `pkg/tui/cart.go` | 9 color swaps |
|
||||
| 12 | `pkg/tui/account.go` | 14 color swaps |
|
||||
| 13 | `pkg/tui/menu.go` | 4 color swaps |
|
||||
| 14 | `pkg/tui/help.go` | 5 color swaps |
|
||||
| 15 | `pkg/tui/confirmation.go` | 5 color swaps |
|
||||
| 16 | `pkg/tui/shipping_form.go` | 4 color swaps + `.WithTheme()` |
|
||||
| 17 | `pkg/tui/payment_form.go` | 5 color swaps + `.WithTheme()` |
|
||||
|
||||
---
|
||||
|
||||
## Color Mapping (hardcoded → theme)
|
||||
|
||||
| Hardcoded | Semantic role | Text style method | Color accessor |
|
||||
|-----------|--------------|-------------------|----------------|
|
||||
| `#FFFFFF` | active / selected text | `m.theme.TextAccent()` | `m.theme.Accent()` |
|
||||
| `#AAAAAA` | body / regular text | `m.theme.TextBody()` | `m.theme.Body()` |
|
||||
| `#666666` | hints / inactive / dim text | `m.theme.TextDim()` | `m.theme.Dim()` |
|
||||
| `#666666` | border foreground | — | `m.theme.Border()` |
|
||||
| `#4682B4` | highlight / brand / steel blue | `m.theme.TextHighlight()` | `m.theme.Highlight()` |
|
||||
| `#999999` | form label text | `m.theme.TextLabel()` | `m.theme.Label()` |
|
||||
| `#00FF00` | success / order placed | `m.theme.TextSuccess()` | `m.theme.Success()` |
|
||||
| `"196"` | error text | `m.theme.TextError()` | `m.theme.Error()` |
|
||||
| `"205"` | loading / spinner | `m.theme.TextLoading()` | — |
|
||||
| `"196"` + `"52"` | error banner bg+fg | `m.theme.PanelError()` | — |
|
||||
| `"C6DDF0"` | user name (light blue) | `m.theme.TextHighlight()` | — |
|
||||
| `#CCCCCC` | muted subtext | `m.theme.TextBody()` | — |
|
||||
|
||||
**Note:** `#666666` is used for BOTH dim text AND border foreground in this project.
|
||||
Both `Dim()` and `Border()` return the same value. Use `Border()` in `BorderForeground()`,
|
||||
use `Dim()` / `TextDim()` for text styling.
|
||||
|
||||
**Note:** Dynamic product colors (`coffee.Color` = `"#8B4513"` etc.) are NOT replaced.
|
||||
Keep those as `lipgloss.Color(coffee.Color)`.
|
||||
|
||||
---
|
||||
|
||||
## Theme Colors (what BasicTheme sets)
|
||||
|
||||
```go
|
||||
background = AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
|
||||
border = AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
|
||||
body = Color("#AAAAAA")
|
||||
dim = Color("#666666")
|
||||
accent = AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
|
||||
highlight = Color("#4682B4")
|
||||
brand = Color("#4682B4") // same as highlight for this project
|
||||
label = Color("#999999")
|
||||
success = Color("#00FF00")
|
||||
error = Color("196")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How the Renderer Works
|
||||
|
||||
The reference implementation creates a `*lipgloss.Renderer` **per SSH session**:
|
||||
|
||||
```go
|
||||
// main.go — SSH handler
|
||||
renderer := bubbletea.MakeRenderer(s) // s = ssh.Session
|
||||
```
|
||||
|
||||
This lets lipgloss detect the correct color profile for each client's terminal
|
||||
(e.g. TrueColor for Ghostty vs 256-color for basic terminals). All style creation
|
||||
inside the theme uses `renderer.NewStyle()` instead of the global `lipgloss.NewStyle()`.
|
||||
|
||||
In this project:
|
||||
- SSH sessions → renderer from `bubbletea.MakeRenderer(s)`
|
||||
- Local / test mode → `lipgloss.DefaultRenderer()`
|
||||
|
||||
---
|
||||
|
||||
## Usage Pattern in Views
|
||||
|
||||
All view functions are methods on `Model`, so `m.theme` is always available.
|
||||
|
||||
**Text-only style (before → after):**
|
||||
```go
|
||||
// before
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Width(18)
|
||||
|
||||
// after
|
||||
m.theme.TextAccent().Bold(true).Width(18)
|
||||
```
|
||||
|
||||
**Border foreground (before → after):**
|
||||
```go
|
||||
// before
|
||||
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666"))
|
||||
|
||||
// after
|
||||
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Border())
|
||||
```
|
||||
|
||||
**Selected item background (before → after):**
|
||||
```go
|
||||
// before
|
||||
lipgloss.NewStyle().Background(lipgloss.Color("#4682B4")).Foreground(lipgloss.Color("#FFFFFF"))
|
||||
|
||||
// after
|
||||
lipgloss.NewStyle().Background(m.theme.Highlight()).Foreground(m.theme.Accent())
|
||||
```
|
||||
|
||||
**`lipgloss.NewStyle()` stays for layout-only styles** (Width, Margin, Padding, Align
|
||||
with no color). Only color-setting calls move to the theme.
|
||||
|
||||
---
|
||||
|
||||
## Huh Form Theme
|
||||
|
||||
`HuhTheme()` creates a `*huh.Theme` applied to shipping and payment forms.
|
||||
|
||||
Applied by chaining `.WithTheme(m.theme.Form())` on the `huh.NewForm(...)` call:
|
||||
|
||||
```go
|
||||
// shipping_form.go and payment_form.go — in buildShippingForm / buildPaymentForm
|
||||
f := huh.NewForm(...).
|
||||
WithShowErrors(false).
|
||||
WithShowHelp(false).
|
||||
WithTheme(m.theme.Form()) // ← add this line
|
||||
```
|
||||
|
||||
Form theme styles:
|
||||
- Focused border: thick left border in accent color (`#FFFFFF`)
|
||||
- Field title + body text: body color (`#AAAAAA`)
|
||||
- Cursor: highlight color (`#4682B4`)
|
||||
- Placeholder text: dim color (`#666666`)
|
||||
- Error: color `"196"`
|
||||
- Blurred fields: hidden border (same styles, border invisible)
|
||||
|
||||
---
|
||||
|
||||
## Constructor Changes (model.go)
|
||||
|
||||
**Before:** `NewModel(username string)` initializes everything.
|
||||
`NewModelWithAuth(fingerprint, pubKey, apiURL, secret, stripeKey)` calls NewModel.
|
||||
|
||||
**After:** A private `newModelWithRenderer(username, renderer)` does the heavy init.
|
||||
Both public constructors call it:
|
||||
|
||||
```go
|
||||
func NewModel(username string) Model {
|
||||
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
|
||||
}
|
||||
|
||||
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint, pubKey, apiURL, secret, stripeKey string) Model {
|
||||
m := newModelWithRenderer("", renderer)
|
||||
m.Fingerprint = fingerprint
|
||||
// ... etc
|
||||
return m
|
||||
}
|
||||
```
|
||||
|
||||
`NewModelWithAuth` signature change: **renderer moves to first parameter**.
|
||||
Update the call in `main.go` accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Theme Methods Quick Reference
|
||||
|
||||
```go
|
||||
// Color accessors (lipgloss.TerminalColor — use in .Foreground() / .Background())
|
||||
m.theme.Accent() // #FFFFFF / #11181C adaptive
|
||||
m.theme.Body() // #AAAAAA
|
||||
m.theme.Dim() // #666666
|
||||
m.theme.Border() // #666666 / #D7DBDF adaptive
|
||||
m.theme.Highlight() // #4682B4
|
||||
m.theme.Brand() // #4682B4
|
||||
m.theme.Label() // #999999
|
||||
m.theme.Success() // #00FF00
|
||||
m.theme.Error() // "196"
|
||||
m.theme.Background() // #000000 / #FBFCFD adaptive
|
||||
|
||||
// Style helpers (lipgloss.Style — chain .Bold(), .Width(), .Padding() etc.)
|
||||
m.theme.Base() // base style, body foreground
|
||||
m.theme.TextAccent() // white text
|
||||
m.theme.TextBody() // gray text (#AAAAAA)
|
||||
m.theme.TextDim() // dim text (#666666)
|
||||
m.theme.TextHighlight() // steel blue text (#4682B4)
|
||||
m.theme.TextBrand() // same as TextHighlight
|
||||
m.theme.TextLabel() // label text (#999999)
|
||||
m.theme.TextSuccess() // green text (#00FF00)
|
||||
m.theme.TextError() // error text ("196")
|
||||
m.theme.TextLoading() // loading pink text ("205")
|
||||
m.theme.PanelError() // error banner: dark red bg + red fg
|
||||
m.theme.Form() // *huh.Theme for form styling
|
||||
```
|
||||
129
go.mod
129
go.mod
@@ -1,73 +1,130 @@
|
||||
module terminalShop
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.6
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
charm.land/wish/v2 v2.0.1
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
|
||||
github.com/charmbracelet/wish v1.4.7
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/riandyrn/otelchi v0.12.2
|
||||
github.com/stripe/stripe-go/v78 v78.12.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
gorm.io/plugin/opentelemetry v0.1.16
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/log/v2 v2.0.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bitfield/gotestdox v0.2.2 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.3 // indirect
|
||||
github.com/charmbracelet/log v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.4 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/input v0.3.4 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/dnephin/pflag v1.0.7 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c // indirect
|
||||
github.com/go-chi/httprate v0.15.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/paulmach/orb v0.11.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/stripe/stripe-go/v78 v78.12.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/clickhouse v0.7.0 // indirect
|
||||
gorm.io/driver/mysql v1.5.7 // indirect
|
||||
gotest.tools/gotestsum v1.13.0 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt
|
||||
gotest.tools/gotestsum
|
||||
)
|
||||
|
||||
323
go.sum
323
go.sum
@@ -1,82 +1,128 @@
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
|
||||
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
|
||||
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
|
||||
charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
|
||||
charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
|
||||
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
|
||||
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
|
||||
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
|
||||
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
|
||||
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
|
||||
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
|
||||
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
|
||||
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
|
||||
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
|
||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4=
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -91,70 +137,205 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/riandyrn/otelchi v0.12.2 h1:6QhGv0LVw/dwjtPd12mnNrl0oEQF4ZAlmHcnlTYbeAg=
|
||||
github.com/riandyrn/otelchi v0.12.2/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0vSF5yt4=
|
||||
github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA=
|
||||
gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
||||
gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
|
||||
gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
186
loadtest/overnight.js
Normal file
186
loadtest/overnight.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import http from 'k6/http';
|
||||
|
||||
const BASE = __ENV.BASE_URL || 'https://api.sshops.uk';
|
||||
const SECRET = __ENV.CLIENT_SECRET || '';
|
||||
|
||||
// Per-VU JWT cache. JWTs are valid for 30 minutes; we refresh ~5 min early.
|
||||
let cachedToken = null;
|
||||
let cachedExpiresAt = 0;
|
||||
let cachedFingerprint = null;
|
||||
|
||||
// SSHPublicKey has a uniqueIndex constraint, so each VU needs a distinct
|
||||
// (fake but unique) public key string. Suffixing with VU+timestamp keeps
|
||||
// the full text unique without affecting the rest of the body.
|
||||
let cachedPubkey = null;
|
||||
|
||||
function getToken() {
|
||||
const now = Date.now();
|
||||
if (cachedToken && now < cachedExpiresAt) return cachedToken;
|
||||
|
||||
if (!cachedFingerprint) {
|
||||
cachedFingerprint = `SHA256:k6-${__VU}-${now.toString(36)}`;
|
||||
cachedPubkey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoadTest${__VU}-${now.toString(36)} k6@vu${__VU}`;
|
||||
}
|
||||
|
||||
const res = http.post(
|
||||
`${BASE}/api/v1/auth/token`,
|
||||
JSON.stringify({
|
||||
client_secret: SECRET,
|
||||
fingerprint: cachedFingerprint,
|
||||
ssh_public_key: cachedPubkey,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_token_refresh' } },
|
||||
);
|
||||
|
||||
if (res.status !== 200) return null;
|
||||
const body = JSON.parse(res.body);
|
||||
cachedToken = body.access_token;
|
||||
cachedExpiresAt = now + (body.expires_in - 300) * 1000;
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const tok = getToken();
|
||||
return tok ? { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' } : null;
|
||||
}
|
||||
|
||||
// Rate limits we are operating under (per-IP, single source IP):
|
||||
// global ........... 200 req/min
|
||||
// /api/v1/auth/* ... 20 req/min (auth/register, auth/user, auth/token)
|
||||
// /api/v1/webhooks/ 60 req/min
|
||||
// All rates below are tuned to stay UNDER these ceilings with headroom so
|
||||
// nothing gets rate-limited and the histogram captures real handler latency.
|
||||
// Total ~148 req/min combined. Wall time ~10h.
|
||||
export const options = {
|
||||
discardResponseBodies: true,
|
||||
summaryTrendStats: ['avg', 'p(95)', 'p(99)', 'max'],
|
||||
scenarios: {
|
||||
browse: {
|
||||
// /products, /products/{id}, /health, /ping. Only the global 200/min
|
||||
// limit applies to these. Hold steady at 100/min all night so we never
|
||||
// get close to the ceiling no matter what other scenarios do.
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: 100,
|
||||
timeUnit: '1m',
|
||||
duration: '10h',
|
||||
preAllocatedVUs: 5,
|
||||
maxVUs: 20,
|
||||
exec: 'browse',
|
||||
},
|
||||
err_404: {
|
||||
// /products/{99xxx} — chi route still matches, returns 404. Counts
|
||||
// against the global 200/min only.
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: 30,
|
||||
timeUnit: '1m',
|
||||
duration: '10h',
|
||||
preAllocatedVUs: 3,
|
||||
maxVUs: 10,
|
||||
exec: 'notFound',
|
||||
},
|
||||
auth_lookup: {
|
||||
// GET /auth/user — counts against the /auth/* 20/min group ceiling
|
||||
// SHARED with auth_warn. We split: 10/min lookup + 8/min warn = 18/min.
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: 10,
|
||||
timeUnit: '1m',
|
||||
duration: '10h',
|
||||
preAllocatedVUs: 2,
|
||||
maxVUs: 5,
|
||||
exec: 'authLookup',
|
||||
},
|
||||
auth_warn: {
|
||||
// POST /auth/token with bad creds — same 20/min group ceiling as above.
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: 8,
|
||||
timeUnit: '1m',
|
||||
duration: '10h',
|
||||
preAllocatedVUs: 2,
|
||||
maxVUs: 5,
|
||||
exec: 'authBad',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
// Smoke-test entry point for `--vus N --duration T` runs.
|
||||
browse();
|
||||
authBad();
|
||||
authLookup();
|
||||
notFound();
|
||||
if (SECRET) {
|
||||
authedBrowse();
|
||||
convertFail();
|
||||
addressBad();
|
||||
}
|
||||
}
|
||||
|
||||
export function browse() {
|
||||
const r = Math.random();
|
||||
if (r < 0.6) http.get(`${BASE}/api/v1/products`, { tags: { name: 'list_products' } });
|
||||
else if (r < 0.8) http.get(`${BASE}/api/v1/products/1`, { tags: { name: 'get_product' } });
|
||||
else if (r < 0.95) http.get(`${BASE}/api/v1/health`, { tags: { name: 'health' } });
|
||||
else http.get(`${BASE}/api/v1/ping`, { tags: { name: 'ping' } });
|
||||
}
|
||||
|
||||
export function authedBrowse() {
|
||||
const h = authHeaders();
|
||||
if (!h) return;
|
||||
const r = Math.random();
|
||||
if (r < 0.4) http.get(`${BASE}/api/v1/cart`, { headers: h, tags: { name: 'authed_get_cart' } });
|
||||
else if (r < 0.6) http.get(`${BASE}/api/v1/cards`, { headers: h, tags: { name: 'authed_list_cards' } });
|
||||
else if (r < 0.8) http.get(`${BASE}/api/v1/addresses`, { headers: h, tags: { name: 'authed_list_addresses' } });
|
||||
else http.get(`${BASE}/api/v1/orders`, { headers: h, tags: { name: 'authed_list_orders' } });
|
||||
}
|
||||
|
||||
export function convertFail() {
|
||||
const h = authHeaders();
|
||||
if (!h) return;
|
||||
// Reset cart, drop one item in, attempt convert. No card/address selected
|
||||
// → ConvertCart hits validation_missing_address → cart_conversion_total
|
||||
// outcome=validation_missing_address + audit.OrderFailed warn.
|
||||
http.del(`${BASE}/api/v1/cart`, null, { headers: h, tags: { name: 'cart_clear' } });
|
||||
http.put(
|
||||
`${BASE}/api/v1/cart/item`,
|
||||
JSON.stringify({ coffee_id: 1, quantity: 1 }),
|
||||
{ headers: h, tags: { name: 'cart_add_item' } },
|
||||
);
|
||||
http.post(`${BASE}/api/v1/cart/convert`, null, { headers: h, tags: { name: 'cart_convert_fail' } });
|
||||
}
|
||||
|
||||
export function addressBad() {
|
||||
const h = authHeaders();
|
||||
if (!h) return;
|
||||
// Junk US zip + street → shippo validation fails → addressLog.Warn.
|
||||
http.post(
|
||||
`${BASE}/api/v1/addresses`,
|
||||
JSON.stringify({
|
||||
name: 'Load Test',
|
||||
street: 'asdfasdfasdf',
|
||||
city: 'Nowhere',
|
||||
state: 'XX',
|
||||
zip: '00000',
|
||||
country: 'US',
|
||||
phone: '+15555550000',
|
||||
}),
|
||||
{ headers: h, tags: { name: 'address_create_bad' } },
|
||||
);
|
||||
}
|
||||
|
||||
export function authBad() {
|
||||
http.post(
|
||||
`${BASE}/api/v1/auth/token`,
|
||||
JSON.stringify({ client_secret: 'definitely_wrong', fingerprint: 'SHA256:fake' }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_bad_creds' } },
|
||||
);
|
||||
}
|
||||
|
||||
export function authLookup() {
|
||||
const fp = `SHA256:${Math.random().toString(36).slice(2, 14)}`;
|
||||
http.get(`${BASE}/api/v1/auth/user?fingerprint=${fp}`, { tags: { name: 'auth_user_lookup' } });
|
||||
}
|
||||
|
||||
export function notFound() {
|
||||
const id = 99000 + Math.floor(Math.random() * 1000);
|
||||
http.get(`${BASE}/api/v1/products/${id}`, { tags: { name: 'product_not_found' } });
|
||||
}
|
||||
43
main.go
43
main.go
@@ -1,3 +1,4 @@
|
||||
// version: 0.1.1
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -7,24 +8,20 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/wish/v2"
|
||||
"charm.land/wish/v2/bubbletea"
|
||||
"charm.land/wish/v2/logging"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/charmbracelet/wish"
|
||||
"github.com/charmbracelet/wish/bubbletea"
|
||||
"github.com/charmbracelet/wish/logging"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
"terminalShop/pkg/tui"
|
||||
)
|
||||
|
||||
const (
|
||||
port = "23456"
|
||||
)
|
||||
|
||||
func listenHost() string {
|
||||
if h := os.Getenv("SSH_HOST"); h != "" {
|
||||
return h
|
||||
@@ -32,23 +29,12 @@ func listenHost() string {
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
func listenPort() string {
|
||||
if p := os.Getenv("SSH_PORT"); p != "" {
|
||||
return p
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
var (
|
||||
apiURL string
|
||||
authFingerprintKey string
|
||||
stripePublicKey string
|
||||
)
|
||||
|
||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
renderer := bubbletea.MakeRenderer(s)
|
||||
|
||||
// Extract SSH public key from session
|
||||
pubKey := s.PublicKey()
|
||||
|
||||
var fingerprint, pubKeyStr string
|
||||
@@ -57,9 +43,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
|
||||
}
|
||||
|
||||
// Create TUI model with user context (no registration screen needed!)
|
||||
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -72,12 +57,20 @@ func main() {
|
||||
// Set API URL and auth key from config for TUI connections
|
||||
apiURL = cfg.APIURL
|
||||
authFingerprintKey = cfg.AuthFingerprintKey
|
||||
stripePublicKey = cfg.StripePublicKey
|
||||
|
||||
// Wire the publishable key into stripe-go's package-level global once.
|
||||
// TUI uses the legacy Tokens API (stripetoken.New) which accepts the
|
||||
// publishable key; per-session writes would race across concurrent SSH
|
||||
// sessions even though they'd all write the same value.
|
||||
// NOTE: assumes single-process — if you ever embed both the SSH and API
|
||||
// binaries together, the last writer wins and one of them silently breaks.
|
||||
stripe.Key = cfg.StripePublicKey
|
||||
|
||||
log.Printf("TUI will connect to API at: %s", apiURL)
|
||||
|
||||
// Create SSH server
|
||||
host := listenHost()
|
||||
p := listenPort()
|
||||
p := cfg.SSHPort
|
||||
s, err := wish.NewServer(
|
||||
wish.WithAddress(fmt.Sprintf("%s:%s", host, p)),
|
||||
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
|
||||
|
||||
@@ -56,11 +56,11 @@ type TokenResponse struct {
|
||||
}
|
||||
|
||||
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
||||
|
||||
reqBody := TokenRequest{
|
||||
Fingerprint: fingerprint,
|
||||
SSHPublicKey: pubKeyStr,
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
|
||||
if err != nil {
|
||||
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
|
||||
@@ -104,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
|
||||
|
||||
// APIResponse represents the standard API response format
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Data map[string]any `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// APIError represents an error from the API
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ProductsResponse represents the products API response
|
||||
@@ -136,7 +136,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch products: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
@@ -166,7 +166,7 @@ func (c *Client) Health() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
@@ -216,7 +216,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register user: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var registerResp RegisterResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil {
|
||||
@@ -280,14 +280,31 @@ type CardResponse struct {
|
||||
}
|
||||
|
||||
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
||||
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
|
||||
// redirect fields and OrderID instead.
|
||||
type ConvertCartResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Order models.Order `json:"order"`
|
||||
Order *models.Order `json:"order,omitempty"`
|
||||
OrderID uint `json:"order_id,omitempty"`
|
||||
PaymentIntentID string `json:"payment_intent_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RedirectToken string `json:"redirect_token,omitempty"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
|
||||
// means the customer needs to complete 3DS at RedirectURL; poll
|
||||
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
|
||||
type CheckoutOutcome struct {
|
||||
Order *models.Order
|
||||
OrderID uint
|
||||
RequiresAction bool
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
// GetCart fetches the user's current cart.
|
||||
func (c *Client) GetCart() (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||
@@ -301,7 +318,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var cartResp CartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
||||
@@ -322,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
||||
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"coffee_id": coffeeID,
|
||||
"quantity": quantity,
|
||||
}
|
||||
@@ -341,7 +358,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set cart item: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var cartResp CartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
||||
@@ -362,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
func (c *Client) SetCartAddress(addressID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"address_id": addressID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -380,7 +397,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set cart address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
@@ -401,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
||||
func (c *Client) SetCartCard(cardID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"card_id": cardID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -419,7 +436,7 @@ func (c *Client) SetCartCard(cardID uint) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set cart card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
@@ -436,6 +453,77 @@ func (c *Client) SetCartCard(cardID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SetSpendLimit(cents *int) error {
|
||||
url := fmt.Sprintf("%s/api/v1/account/spend-limit", c.BaseURL)
|
||||
|
||||
body := map[string]any{
|
||||
"self_limit_cents": cents,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set spend limit: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("failed to set spend limit")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPrivacyMode sets the users account-level privacy default ("keep as little
|
||||
// as possible"). A soft default the checkout can override per order.
|
||||
func (c *Client) SetPrivacyMode(on bool) error {
|
||||
url := fmt.Sprintf("%s/api/v1/account/privacy-mode", c.BaseURL)
|
||||
|
||||
jsonData, err := json.Marshal(map[string]any{"privacy_mode": on})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set privacy mode: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
if !result.Success {
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearCart removes all items from the user's cart.
|
||||
func (c *Client) ClearCart() error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||
@@ -449,7 +537,7 @@ func (c *Client) ClearCart() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
@@ -467,10 +555,41 @@ func (c *Client) ClearCart() error {
|
||||
}
|
||||
|
||||
// ConvertCart converts the cart into an order, charging the saved card.
|
||||
func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
// Returns a CheckoutOutcome that either carries the completed Order or
|
||||
// indicates the customer must complete 3DS authentication via RedirectURL.
|
||||
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
|
||||
return c.convertCart(nil)
|
||||
}
|
||||
|
||||
// ConvertCartEphemeral converts the cart in privacy mode: it charges a one-time
|
||||
// card (cardToken) that is never saved and is detached after the charge settles.
|
||||
// Used when the customer leaves "save this card" unchecked at checkout.
|
||||
func (c *Client) ConvertCartEphemeral(cardToken string) (*CheckoutOutcome, error) {
|
||||
jsonData, err := json.Marshal(map[string]any{
|
||||
"ephemeral": true,
|
||||
"card_token": cardToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
return c.convertCart(jsonData)
|
||||
}
|
||||
|
||||
// convertCart POSTs to /cart/convert with an optional JSON body (nil = saved-card
|
||||
// flow) parses the shared response into a CheckoutOutcome.
|
||||
func (c *Client) convertCart(jsonData []byte) (*CheckoutOutcome, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
||||
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
var req *http.Request
|
||||
var err error
|
||||
if jsonData != nil {
|
||||
req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
} else {
|
||||
req, err = http.NewRequest("POST", url, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -479,7 +598,7 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var convertResp ConvertCartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
|
||||
@@ -493,7 +612,107 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
return nil, fmt.Errorf("failed to convert cart")
|
||||
}
|
||||
|
||||
return &convertResp.Data.Order, nil
|
||||
if convertResp.Data.Status == "requires_action" {
|
||||
return &CheckoutOutcome{
|
||||
OrderID: convertResp.Data.OrderID,
|
||||
RequiresAction: true,
|
||||
RedirectURL: convertResp.Data.RedirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if convertResp.Data.Order == nil {
|
||||
return nil, fmt.Errorf("malformed convert response: missing order")
|
||||
}
|
||||
return &CheckoutOutcome{
|
||||
Order: convertResp.Data.Order,
|
||||
OrderID: convertResp.Data.Order.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type OrderStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrderStatus(id uint) (string, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch order status: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var sResp OrderStatusResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode order status response: %w", err)
|
||||
}
|
||||
if !sResp.Success {
|
||||
if sResp.Error != nil {
|
||||
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
|
||||
}
|
||||
return "", fmt.Errorf("failed to fetch order status")
|
||||
}
|
||||
return sResp.Data.Status, nil
|
||||
}
|
||||
|
||||
// RetryAuthOutcome is the result of POST /orders/{id}/retry-auth.
|
||||
// status=="requires_action" -> RedirectURL is a fresh QR target; resume polling.
|
||||
// Status=="succeeded" -> stripe approved frictionless; keep polling for the
|
||||
// webhook to flip status=paid (RedirectURL will be empty)
|
||||
type RetryAuthOutcome struct {
|
||||
OrderID uint
|
||||
Status string
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
type retryAuthResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
OrderID uint `json:"order_id"`
|
||||
Status string `json:"status"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RetryAuth asks the server to re-Confirm the existing PaymentIntent for the
|
||||
// order, producing a new 3DS challenge URL. Used by the TUI's authFailed
|
||||
// screen so the customer can recover without re-entering the checkout flow.
|
||||
func (c *Client) RetryAuth(orderID uint) (*RetryAuthOutcome, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/retry-auth", c.BaseURL, orderID)
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create retry request: %w", err)
|
||||
}
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send retry request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var rResp retryAuthResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode retry response: %w", err)
|
||||
}
|
||||
if !rResp.Success {
|
||||
if rResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", rResp.Error.Code, rResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to retry authentication")
|
||||
}
|
||||
return &RetryAuthOutcome{
|
||||
OrderID: rResp.Data.OrderID,
|
||||
Status: rResp.Data.Status,
|
||||
RedirectURL: rResp.Data.RedirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCards fetches all saved cards for the user.
|
||||
@@ -509,7 +728,7 @@ func (c *Client) GetCards() ([]models.Card, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch cards: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var cardsResp CardsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
|
||||
@@ -552,7 +771,7 @@ func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var cardResp CardResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
|
||||
@@ -589,11 +808,11 @@ func (c *Client) CollectCard() (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to collect card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var collectResp CollectCardResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
|
||||
return "", fmt.Errorf("failed to devode collect response: %w", err)
|
||||
return "", fmt.Errorf("failed to decode collect response: %w", err)
|
||||
}
|
||||
|
||||
if !collectResp.Success {
|
||||
@@ -618,7 +837,7 @@ func (c *Client) DeleteCard(id uint) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
|
||||
@@ -635,6 +854,20 @@ type OrderResponse struct {
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RefundRequest is the customer-facing refund request payload.
|
||||
type RefundRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type refundRequestResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
||||
|
||||
@@ -647,7 +880,7 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch orders: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var ordersResp OrderResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil {
|
||||
@@ -663,6 +896,42 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
return ordersResp.Data.Orders, nil
|
||||
}
|
||||
|
||||
// CreateRefundRequest posts a refund request for an order.
|
||||
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
|
||||
|
||||
jsonData, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal refund request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create refund request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send refund request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var refundResp refundRequestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
|
||||
return fmt.Errorf("failed to decode refund response: %w", err)
|
||||
}
|
||||
|
||||
if !refundResp.Success {
|
||||
if refundResp.Error != nil {
|
||||
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("failed to send refund request")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// The Addresses API response
|
||||
type AddressResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -685,7 +954,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) {
|
||||
return nil, fmt.Errorf("failed to fetch addresses: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var addrResp AddressResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
|
||||
@@ -731,7 +1000,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -768,7 +1037,7 @@ func (c *Client) DeleteAddress(id uint) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
|
||||
@@ -803,7 +1072,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) {
|
||||
if err != nil {
|
||||
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
3
pkg/api/doc.go
Normal file
3
pkg/api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package api is the client side of the house: it talks to our HTTP backend
|
||||
// so other Go code doesn't have to hand-roll requests.
|
||||
package api
|
||||
@@ -1,124 +1,446 @@
|
||||
// Package audit writes structured JSON logs for financial events.
|
||||
// Package audit writes structured logs for financial events.
|
||||
// Every card addition, order creation, payment, and refund is recorded here.
|
||||
// Pipe this output to your log aggregator (Datadog, Loki, CloudWatch, etc.).
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EventOrderRequiresAction is the audit event-type string emitted whenever a
|
||||
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler
|
||||
// can count retry attempts without hardcoding the string literal.
|
||||
const EventOrderRequiresAction = "order_requires_action"
|
||||
|
||||
// EventOrder3DSAbandoned is the audit event-type string emitted by the
|
||||
// abandoned-3DS sweep when it flips a stale requires_action row to failed.
|
||||
// Distinct from EventOrderRequiresAction (caveat #17) so the retry-cap count
|
||||
// stays decoupled from sweep activity
|
||||
const EventOrder3DSAbandoned = "order_3ds_abandoned"
|
||||
|
||||
// EventCartRejected is the audit event-type string emitted when the per-order
|
||||
// spend cap blocks a checkout BEFORE an order row is created. The event lives
|
||||
// in slog only, persist() no-ops on zero order id, which is the correct shape
|
||||
// here (cap rejection is upstream of order state). See caveat #13.
|
||||
const EventCartRejected = "cart_rejected"
|
||||
|
||||
type eventType string
|
||||
|
||||
const (
|
||||
eventCardAdded eventType = "card_added"
|
||||
eventCardDeleted eventType = "card_deleted"
|
||||
eventOrderCreated eventType = "order_created"
|
||||
eventOrderPaid eventType = "order_paid"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventCardAdded eventType = "card_added"
|
||||
eventCardDeleted eventType = "card_deleted"
|
||||
eventCardExpired eventType = "card_expired"
|
||||
eventOrderCreated eventType = "order_created"
|
||||
eventOrderPaid eventType = "order_paid"
|
||||
eventOrderShipped eventType = "order_shipped"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRequiresAction eventType = EventOrderRequiresAction
|
||||
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
|
||||
eventCartRejected eventType = EventCartRejected
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
eventLabelOrphaned eventType = "label_orphaned"
|
||||
eventTrackingUpdated eventType = "tracking_updated"
|
||||
eventOrderDelivered eventType = "order_delivered"
|
||||
eventTrackingMarkedManually eventType = "tracking_marked_manually"
|
||||
eventUserOrderCapSet eventType = "user_order_cap_set"
|
||||
eventUserSelfLimitSet eventType = "user_self_limit_set"
|
||||
eventUserPrivacyModeSet eventType = "user_privacy_mode_set"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
Time time.Time `json:"time"`
|
||||
Event eventType `json:"event"`
|
||||
UserID uint `json:"user_id,omitempty"`
|
||||
OrderID uint `json:"order_id,omitempty"`
|
||||
CardID uint `json:"card_id,omitempty"`
|
||||
Amount int `json:"amount_cents,omitempty"`
|
||||
StripeID string `json:"stripe_id,omitempty"`
|
||||
CardLast4 string `json:"card_last4,omitempty"`
|
||||
CardBrand string `json:"card_brand,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
Event eventType
|
||||
UserID uint
|
||||
OrderID uint
|
||||
CardID uint
|
||||
Amount int
|
||||
StripeID string
|
||||
CardLast4 string
|
||||
CardBrand string
|
||||
Carrier string
|
||||
Tracking string
|
||||
Status string
|
||||
Error string
|
||||
Actor string
|
||||
TotalCents int
|
||||
CapCents int
|
||||
}
|
||||
|
||||
func emit(e event) {
|
||||
e.Time = time.Now().UTC()
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
log.Printf("[audit] failed to marshal event: %v", err)
|
||||
return
|
||||
func (e event) attrs() []any {
|
||||
a := []any{"event", string(e.Event)}
|
||||
if e.UserID != 0 {
|
||||
a = append(a, "user_id", e.UserID)
|
||||
}
|
||||
log.Printf("[AUDIT] %s", b)
|
||||
if e.OrderID != 0 {
|
||||
a = append(a, "order_id", e.OrderID)
|
||||
}
|
||||
if e.CardID != 0 {
|
||||
a = append(a, "card_id", e.CardID)
|
||||
}
|
||||
if e.Amount != 0 {
|
||||
a = append(a, "amount_cents", e.Amount)
|
||||
}
|
||||
if e.StripeID != "" {
|
||||
a = append(a, "stripe_id", e.StripeID)
|
||||
}
|
||||
if e.CardLast4 != "" {
|
||||
a = append(a, "card_last4", e.CardLast4)
|
||||
}
|
||||
if e.CardBrand != "" {
|
||||
a = append(a, "card_brand", e.CardBrand)
|
||||
}
|
||||
if e.Carrier != "" {
|
||||
a = append(a, "carrier", e.Carrier)
|
||||
}
|
||||
if e.Tracking != "" {
|
||||
a = append(a, "tracking", e.Tracking)
|
||||
}
|
||||
if e.Status != "" {
|
||||
a = append(a, "status", e.Status)
|
||||
}
|
||||
if e.Error != "" {
|
||||
a = append(a, "error", e.Error)
|
||||
}
|
||||
if e.Actor != "" {
|
||||
a = append(a, "actor", e.Actor)
|
||||
}
|
||||
if e.TotalCents != 0 {
|
||||
a = append(a, "total_cents", e.TotalCents)
|
||||
}
|
||||
if e.CapCents != 0 {
|
||||
a = append(a, "cap_cents", e.CapCents)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// CardAdded records that a user saved a new payment method.
|
||||
func CardAdded(userID, cardID uint, brand, last4 string) {
|
||||
emit(event{
|
||||
e := event{
|
||||
Event: eventCardAdded,
|
||||
UserID: userID,
|
||||
CardID: cardID,
|
||||
CardBrand: brand,
|
||||
CardLast4: last4,
|
||||
})
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(0, eventCardAdded, e)
|
||||
}
|
||||
|
||||
// CardDeleted records that a user removed a saved payment method.
|
||||
func CardDeleted(userID, cardID uint) {
|
||||
emit(event{
|
||||
e := event{
|
||||
Event: eventCardDeleted,
|
||||
UserID: userID,
|
||||
CardID: cardID,
|
||||
})
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(0, eventCardDeleted, e)
|
||||
}
|
||||
|
||||
// CardExpired records that a saved payment method expired from inactivity
|
||||
func CardExpired(userID, cardID uint) {
|
||||
e := event{
|
||||
Event: eventCardExpired,
|
||||
UserID: userID,
|
||||
CardID: cardID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(0, eventCardExpired, e)
|
||||
}
|
||||
|
||||
// OrderCreated records that an order record was created (before payment).
|
||||
func OrderCreated(userID, orderID uint, amountCents int) {
|
||||
emit(event{
|
||||
e := event{
|
||||
Event: eventOrderCreated,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Amount: amountCents,
|
||||
})
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderCreated, e)
|
||||
}
|
||||
|
||||
// OrderPaid records a successful charge.
|
||||
func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID string) {
|
||||
emit(event{
|
||||
e := event{
|
||||
Event: eventOrderPaid,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Amount: amountCents,
|
||||
StripeID: stripePaymentIntentID,
|
||||
})
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderPaid, e)
|
||||
}
|
||||
|
||||
// OrderFailed records a failed charge attempt.
|
||||
// OrderShipped records that an order has been marked as shipped with carrier metadata
|
||||
func OrderShipped(userID, orderID uint, carrier, trackingNumber string) {
|
||||
e := event{
|
||||
Event: eventOrderShipped,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Carrier: carrier,
|
||||
Tracking: trackingNumber,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderShipped, e)
|
||||
}
|
||||
|
||||
// OrderFailed records a failed charge attempt. Logged at WARN.
|
||||
func OrderFailed(userID, orderID uint, amountCents int, reason string) {
|
||||
emit(event{
|
||||
e := event{
|
||||
Event: eventOrderFailed,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Amount: amountCents,
|
||||
Error: reason,
|
||||
})
|
||||
}
|
||||
slog.Warn("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderFailed, e)
|
||||
}
|
||||
|
||||
// OrderRefunded records a refund.
|
||||
func OrderRefunded(userID, orderID uint, amountCents int, stripeRefundID string) {
|
||||
emit(event{
|
||||
e := event{
|
||||
Event: eventOrderRefunded,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Amount: amountCents,
|
||||
StripeID: stripeRefundID,
|
||||
})
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderRefunded, e)
|
||||
}
|
||||
|
||||
// PaymentCritical records the dangerous state where Stripe charged the card
|
||||
// but the local database transaction failed. This MUST be investigated and
|
||||
// manually reconciled. Emit a loud log line that monitoring can alert on.
|
||||
// manually reconciled. Logged at ERROR so monitoring can alert on it.
|
||||
func PaymentCritical(orderID uint, stripePaymentIntentID string, err error) {
|
||||
emit(event{
|
||||
e := event{
|
||||
Event: eventPaymentCritical,
|
||||
OrderID: orderID,
|
||||
StripeID: stripePaymentIntentID,
|
||||
Error: err.Error(),
|
||||
})
|
||||
// Second line so it's impossible to miss in plain-text log tails.
|
||||
log.Printf("[AUDIT][CRITICAL] order %d was charged (pi=%s) but DB commit failed: %v — REQUIRES MANUAL RECONCILIATION",
|
||||
orderID, stripePaymentIntentID, err)
|
||||
}
|
||||
slog.Error("audit", e.attrs()...)
|
||||
persist(orderID, eventPaymentCritical, e)
|
||||
}
|
||||
|
||||
func LabelPurchased(orderID uint, carrier, trackingNumber, shippoTxID string, costCents int) {
|
||||
e := event{
|
||||
Event: eventLabelPurchased,
|
||||
OrderID: orderID,
|
||||
Carrier: carrier,
|
||||
Tracking: trackingNumber,
|
||||
StripeID: shippoTxID,
|
||||
Amount: costCents,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventLabelPurchased, e)
|
||||
}
|
||||
|
||||
func LabelOrphaned(orderID uint, shippoTxID, trackingNumber string, err error) {
|
||||
e := event{
|
||||
Event: eventLabelOrphaned,
|
||||
OrderID: orderID,
|
||||
StripeID: shippoTxID,
|
||||
Tracking: trackingNumber,
|
||||
Error: err.Error(),
|
||||
}
|
||||
slog.Error("audit", e.attrs()...)
|
||||
persist(orderID, eventLabelOrphaned, e)
|
||||
}
|
||||
|
||||
func TrackingUpdated(orderID uint, carrier, trackingNumber, status string) {
|
||||
e := event{
|
||||
Event: eventTrackingUpdated,
|
||||
OrderID: orderID,
|
||||
Carrier: carrier,
|
||||
Tracking: trackingNumber,
|
||||
Status: status,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventTrackingUpdated, e)
|
||||
}
|
||||
|
||||
func OrderDelivered(orderID uint, trackingNumber string) {
|
||||
e := event{
|
||||
Event: eventOrderDelivered,
|
||||
OrderID: orderID,
|
||||
Tracking: trackingNumber,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderDelivered, e)
|
||||
}
|
||||
|
||||
// TrackingMarkedManually records operator made tracking status mark
|
||||
// from the slack interactivity surface. Actor is the slack username that
|
||||
// clicked the button
|
||||
func TrackingMarkedManually(orderID uint, status, actor string) {
|
||||
e := event{
|
||||
Event: eventTrackingMarkedManually,
|
||||
OrderID: orderID,
|
||||
Status: status,
|
||||
Actor: actor,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventTrackingMarkedManually, e)
|
||||
}
|
||||
|
||||
// db is the optional persistence target. nil = slog-only.
|
||||
// set once at startup with SetDB, funcs stay slog-primary regardless.
|
||||
var db *gorm.DB
|
||||
|
||||
// SetDB wires the audit package to a database for persistent event rows.
|
||||
// call once after database.Migrate. Audit funcs still write slog when
|
||||
// db is nil, so tests don't need to wire this.
|
||||
func SetDB(d *gorm.DB) { db = d }
|
||||
|
||||
func persist(orderID uint, t eventType, e event) {
|
||||
if db == nil || orderID == 0 {
|
||||
return
|
||||
}
|
||||
actor := e.Actor
|
||||
if actor == "" {
|
||||
if e.UserID > 0 {
|
||||
actor = fmt.Sprintf("user:%d", e.UserID)
|
||||
} else {
|
||||
actor = "system"
|
||||
}
|
||||
}
|
||||
payload, err := json.Marshal(attrsToMap(e.attrs()))
|
||||
if err != nil {
|
||||
slog.Error("audit persist marshal", "error", err, "event", string(t))
|
||||
return
|
||||
}
|
||||
row := &models.OrderEvent{
|
||||
OrderID: orderID,
|
||||
Type: string(t),
|
||||
Payload: string(payload),
|
||||
Actor: actor,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
slog.Error("audit persist write", "error", err, "event", string(t), "order_id", orderID)
|
||||
}
|
||||
}
|
||||
|
||||
func attrsToMap(a []any) map[string]any {
|
||||
m := make(map[string]any, len(a)/2)
|
||||
for i := 0; i+1 < len(a); i += 2 {
|
||||
k, ok := a[i].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[k] = a[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||
e := event{
|
||||
Event: eventOrderRequiresAction,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
StripeID: paymentIntentID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderRequiresAction, e)
|
||||
}
|
||||
|
||||
// Order3DSAbandoned records the abandoned-3DS sweep flipping a stale
|
||||
// requires_action row to failed because Stripe still reports the underlying
|
||||
// PaymentIntent as outstanding past the sweep threshold.
|
||||
func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
|
||||
e := event{
|
||||
Event: eventOrder3DSAbandoned,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
StripeID: paymentIntentID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrder3DSAbandoned, e)
|
||||
}
|
||||
|
||||
// CartRejected records the per-order cap blocking a checkout BEFORE an
|
||||
// order row is created. Slog-only by design: cap rejection runs upstream
|
||||
// of order creation so there is no order id to scope a persistent
|
||||
// OrderEvent row. persist() is intentionally not called - future refactors
|
||||
// of the persist() zero-id guard must not silently start writing rows here.
|
||||
// Counterpart to the validation_over_limit Prometheus counter.
|
||||
func CartRejected(userID uint, totalCents, capCents int) {
|
||||
e := event{
|
||||
Event: eventCartRejected,
|
||||
UserID: userID,
|
||||
TotalCents: totalCents,
|
||||
CapCents: capCents,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
}
|
||||
|
||||
// UserOrderCapSet records an operator changing a user's per-user order-spend
|
||||
// cap override (User.MaxOrderCents), an admin-gated action. A nil capCents means
|
||||
// the override was cleared and the user reverts to the global MAX_ORDER_CENTS;
|
||||
// Status distinguishes "set" from "cleared" so a cap set to 0 (off-switch, which
|
||||
// attrs() omits because cap_cents == 0) is still legible in the log.
|
||||
func UserOrderCapSet(actor string, userID uint, capCents *int) {
|
||||
e := event{
|
||||
Event: eventUserOrderCapSet,
|
||||
Actor: actor,
|
||||
UserID: userID,
|
||||
}
|
||||
if capCents == nil {
|
||||
e.Status = "cleared"
|
||||
} else {
|
||||
e.Status = "set"
|
||||
e.CapCents = *capCents
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
}
|
||||
|
||||
// UserSelfLimitSet records a customer changing their own self-service spend
|
||||
// limit (User.SelfLimitCents) via the JWT-authed account endpoint. Kept
|
||||
// deliberately distinct from UserOrderCapSet (the operator's admin-gated
|
||||
// override) so the customer action and the operator action are never conflated
|
||||
// in the audit trail. A nil capCents means the self-limit was cleared (the user
|
||||
// reverts to the admin/global cap). Unlike the admin cap, an explicit 0 here is
|
||||
// a real "block everything" ceiling, not an off-switch, so Status distinguishes
|
||||
// "set" from "cleared" and a cap of 0 (which attrs() omits) stays legible.
|
||||
func UserSelfLimitSet(userID uint, capCents *int) {
|
||||
e := event{
|
||||
Event: eventUserSelfLimitSet,
|
||||
Actor: "self",
|
||||
UserID: userID,
|
||||
}
|
||||
if capCents == nil {
|
||||
e.Status = "cleared"
|
||||
} else {
|
||||
e.Status = "set"
|
||||
e.CapCents = *capCents
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
}
|
||||
|
||||
// UserPrivacyModeSet records a customer toggling their account-level privacy
|
||||
// ("keep as little as possible") default via the JWT-authed account endpoint.
|
||||
// Actor "self" mirrors UserSelfLimitSet; Status carries the new state so the
|
||||
// trail reads cleanly without a dedicated column.
|
||||
func UserPrivacyModeSet(userID uint, enabled bool) {
|
||||
e := event{
|
||||
Event: eventUserPrivacyModeSet,
|
||||
Actor: "self",
|
||||
UserID: userID,
|
||||
}
|
||||
if enabled {
|
||||
e.Status = "enabled"
|
||||
} else {
|
||||
e.Status = "disabled"
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
}
|
||||
|
||||
41
pkg/audit/audit_test.go
Normal file
41
pkg/audit/audit_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCartRejected(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
CartRejected(42, 25000, 20000)
|
||||
|
||||
raw := strings.TrimSpace(buf.String())
|
||||
if raw == "" {
|
||||
t.Fatalf("CartRejected: no slog output captured")
|
||||
}
|
||||
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &rec); err != nil {
|
||||
t.Fatalf("decode slog record: %v\nraw: %s", err, raw)
|
||||
}
|
||||
|
||||
if got, want := rec["event"], "cart_rejected"; got != want {
|
||||
t.Errorf("event: want %q, got %v", want, got)
|
||||
}
|
||||
if n, _ := rec["user_id"].(float64); int(n) != 42 {
|
||||
t.Errorf("user_id want 42, got %v", rec["user_id"])
|
||||
}
|
||||
if n, _ := rec["total_cents"].(float64); int(n) != 25000 {
|
||||
t.Errorf("total_cents: want 25000, got %v", rec["total_cents"])
|
||||
}
|
||||
if n, _ := rec["cap_cents"].(float64); int(n) != 20000 {
|
||||
t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"])
|
||||
}
|
||||
}
|
||||
3
pkg/auth/doc.go
Normal file
3
pkg/auth/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package auth sorts out who you are: logging in with an SSH key and minting
|
||||
// the JWT you carry around afterwards.
|
||||
package auth
|
||||
@@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string,
|
||||
|
||||
// ValidateToken validates a JWT token and returns the claims
|
||||
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
// Verify signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func setupAuthTestDB(t *testing.T) string {
|
||||
t.Helper()
|
||||
testDB := "test_ssh_auth.db"
|
||||
os.Remove(testDB)
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
@@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey {
|
||||
|
||||
func TestAuthenticateSSHKeyNewUser(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) {
|
||||
|
||||
func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKey(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
|
||||
func TestIsSSHKeyRegistered(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) {
|
||||
}
|
||||
|
||||
// After authentication (which auto-creates)
|
||||
svc.AuthenticateSSHKey(key)
|
||||
_, _, _ = svc.AuthenticateSSHKey(key)
|
||||
|
||||
registered, err = svc.IsSSHKeyRegistered(key)
|
||||
if err != nil {
|
||||
|
||||
253
pkg/bring/booking.go
Normal file
253
pkg/bring/booking.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package bring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"terminalShop/pkg/shipping"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const bookingBaseURL = "https://api.bring.com/booking-api/api/booking"
|
||||
|
||||
type BookingClient struct {
|
||||
apiUID string
|
||||
apiKey string
|
||||
customerNumber string
|
||||
testMode bool
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewBookingClient(apiUID string, apiKey string, customerNumber string, testMode bool) *BookingClient {
|
||||
return &BookingClient{
|
||||
apiUID: apiUID,
|
||||
apiKey: apiKey,
|
||||
customerNumber: customerNumber,
|
||||
testMode: testMode,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type BookingAddress struct {
|
||||
Name string
|
||||
Street1 string
|
||||
Street2 string
|
||||
City string
|
||||
Country string
|
||||
Zip string
|
||||
Phone string
|
||||
Email string
|
||||
ContactName string
|
||||
}
|
||||
|
||||
type LineItem struct {
|
||||
Title string
|
||||
Quantity int
|
||||
WeightKg float64
|
||||
}
|
||||
|
||||
func (c *BookingClient) CreateLabel(ctx context.Context, to BookingAddress, email string, items []LineItem) (*shipping.LabelResult, error) {
|
||||
from := fromAddressNO()
|
||||
if from.Street1 == "" {
|
||||
return nil, fmt.Errorf("SHIP_FROM_NO_STREET1 not configured")
|
||||
}
|
||||
if c.customerNumber == "" {
|
||||
return nil, fmt.Errorf("BRING_CUSTOMER_NUMBER not configured")
|
||||
}
|
||||
|
||||
totalKg := 0.0
|
||||
for _, item := range items {
|
||||
totalKg += float64(item.Quantity) * item.WeightKg
|
||||
}
|
||||
if totalKg <= 0 {
|
||||
return nil, fmt.Errorf("order has zero shipping weight")
|
||||
}
|
||||
|
||||
oslo, err := time.LoadLocation("Europe/Oslo")
|
||||
if err != nil {
|
||||
oslo = time.UTC
|
||||
}
|
||||
shippingAt := time.Now().In(oslo).Add(1 * time.Hour).Format("2006-01-02T15:04:05-07:00")
|
||||
|
||||
payload := map[string]any{
|
||||
"testIndicator": c.testMode,
|
||||
"schemaVersion": 1,
|
||||
"consignments": []map[string]any{{
|
||||
"shippingDateTime": shippingAt,
|
||||
"parties": map[string]any{
|
||||
"sender": partyPayload(from, ""),
|
||||
"recipient": partyPayload(to, email),
|
||||
},
|
||||
"product": map[string]any{
|
||||
"id": "SERVICEPAKKE",
|
||||
"customerNumber": c.customerNumber,
|
||||
},
|
||||
"packages": []map[string]any{{
|
||||
"weightInKg": roundN(totalKg, 3),
|
||||
"goodsDescription": "Roasted coffee beans",
|
||||
"dimensions": map[string]any{
|
||||
"heightInCm": 30,
|
||||
"widthInCm": 30,
|
||||
"lengthInCm": 30,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
var resp bookingResponse
|
||||
if err := c.doJSON(ctx, bookingBaseURL, payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Consignments) == 0 {
|
||||
return nil, fmt.Errorf("bring returned no consignments")
|
||||
}
|
||||
c0 := resp.Consignments[0]
|
||||
if c0.Confirmation == nil {
|
||||
return nil, fmt.Errorf("bring booking failed: %s", firstError(c0.Errors))
|
||||
}
|
||||
|
||||
confirmation := c0.Confirmation
|
||||
if len(confirmation.Packages) == 0 || confirmation.Packages[0].TrackingNumber == "" {
|
||||
return nil, fmt.Errorf("bring confirmation missing tracking number")
|
||||
}
|
||||
|
||||
labelURL := ""
|
||||
if confirmation.Links != nil {
|
||||
labelURL = confirmation.Links.Labels
|
||||
}
|
||||
|
||||
tracking := confirmation.Packages[0].TrackingNumber
|
||||
trackingURL := fmt.Sprintf("https://sporing.posten.no/sporing/%s", tracking)
|
||||
|
||||
return &shipping.LabelResult{
|
||||
TransactionID: confirmation.ConsignmentNumber,
|
||||
Carrier: "BRING",
|
||||
ServiceLevel: "SERVICEPAKKE",
|
||||
TrackingNumber: tracking,
|
||||
TrackingURL: trackingURL,
|
||||
LabelURL: labelURL,
|
||||
CostCents: 0,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
type bookingResponse struct {
|
||||
Consignments []struct {
|
||||
Confirmation *struct {
|
||||
ConsignmentNumber string `json:"consignmentNumber"`
|
||||
Links *struct {
|
||||
Labels string `json:"labels"`
|
||||
} `json:"links"`
|
||||
Packages []struct {
|
||||
CorrelationID string `json:"correlationId"`
|
||||
TrackingNumber string `json:"trackingNumber"`
|
||||
} `json:"packages"`
|
||||
} `json:"confirmation"`
|
||||
Errors []struct {
|
||||
Messages []struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"messages"`
|
||||
} `json:"errors"`
|
||||
} `json:"consignments"`
|
||||
}
|
||||
|
||||
func firstError(errs []struct {
|
||||
Messages []struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"messages"`
|
||||
}) string {
|
||||
if len(errs) == 0 || len(errs[0].Messages) == 0 {
|
||||
return "no detail"
|
||||
}
|
||||
return errs[0].Messages[0].Message
|
||||
}
|
||||
|
||||
func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out any) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Mybring-API-Uid", c.apiUID)
|
||||
req.Header.Set("X-Mybring-API-Key", c.apiKey)
|
||||
req.Header.Set("X-Mybring-API-Customer-Number", c.customerNumber)
|
||||
clientURL := os.Getenv("BRING_CLIENT_URL")
|
||||
if clientURL == "" {
|
||||
clientURL = "https://sshops.uk"
|
||||
}
|
||||
req.Header.Set("X-Bring-Client-URL", clientURL)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("bring booking %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roundN(v float64, n int) float64 {
|
||||
pow := 1.0
|
||||
for i := 0; i < n; i++ {
|
||||
pow *= 10
|
||||
}
|
||||
if v >= 0 {
|
||||
return float64(int64(v*pow+0.5)) / pow
|
||||
}
|
||||
return float64(int64(v*pow-0.5)) / pow
|
||||
}
|
||||
|
||||
func partyPayload(a BookingAddress, email string) map[string]any {
|
||||
m := map[string]any{
|
||||
"name": a.Name,
|
||||
"addressLine": a.Street1,
|
||||
"postalCode": a.Zip,
|
||||
"city": a.City,
|
||||
"countryCode": a.Country,
|
||||
}
|
||||
if a.Street2 != "" {
|
||||
m["addressLine2"] = a.Street2
|
||||
}
|
||||
if email != "" || a.Phone != "" {
|
||||
m["contact"] = map[string]any{"name": a.Name, "email": email, "phoneNumber": a.Phone}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func fromAddressNO() BookingAddress {
|
||||
return BookingAddress{
|
||||
Name: os.Getenv("SHIP_FROM_NO_NAME"),
|
||||
Street1: os.Getenv("SHIP_FROM_NO_STREET1"),
|
||||
Street2: os.Getenv("SHIP_FROM_NO_STREET2"),
|
||||
City: os.Getenv("SHIP_FROM_NO_CITY"),
|
||||
Country: os.Getenv("SHIP_FROM_NO_COUNTRY"),
|
||||
Zip: os.Getenv("SHIP_FROM_NO_ZIP"),
|
||||
Phone: os.Getenv("SHIP_FROM_NO_PHONE"),
|
||||
Email: os.Getenv("SHIP_FROM_NO_EMAIL"),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package bring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const baseURL = "https://api.bring.com/address"
|
||||
@@ -26,7 +29,8 @@ func NewClient(apiUID, apiKey string) *Client {
|
||||
apiUID: apiUID,
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -42,7 +46,6 @@ type Address struct {
|
||||
Phone string
|
||||
}
|
||||
|
||||
|
||||
type validationMatch struct {
|
||||
StreetName string `json:"street_name"`
|
||||
HouseNumber int `json:"house_number"`
|
||||
@@ -80,7 +83,7 @@ func parseStreet(street string) (name string, number string) {
|
||||
return strings.TrimSpace(street), ""
|
||||
}
|
||||
|
||||
func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, error) {
|
||||
streetName, streetNumber := parseStreet(addr.Street1)
|
||||
|
||||
params := url.Values{}
|
||||
@@ -94,7 +97,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/no/validation?%s", baseURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -107,7 +110,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bring request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
||||
3
pkg/bring/doc.go
Normal file
3
pkg/bring/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package bring talks to Bring, the Norwegian postal service, for booking
|
||||
// shipments and printing labels.
|
||||
package bring
|
||||
@@ -3,8 +3,10 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@@ -12,20 +14,26 @@ import (
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
APIPort string
|
||||
APIURL string
|
||||
AppURL string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
StripeSecretKey string
|
||||
StripePublicKey string
|
||||
StripeWebhookSecret string
|
||||
ShippoAPIKey string
|
||||
AuthFingerprintKey string
|
||||
BringAPIUID string
|
||||
BringAPIKey string
|
||||
APIPort string
|
||||
APIURL string
|
||||
AppURL string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
StripeSecretKey string
|
||||
StripePublicKey string
|
||||
StripeWebhookSecret string
|
||||
ShippoAPIKey string
|
||||
AuthFingerprintKey string
|
||||
BringAPIUID string
|
||||
BringAPIKey string
|
||||
BringCustomerNumber string
|
||||
ShippoWebhookSecret string
|
||||
SlackSigningSecret string
|
||||
AdminAPIKey string
|
||||
MaxOrderCents int
|
||||
Abandoned3DSThresholdMinutes int
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and validates required secrets.
|
||||
@@ -44,20 +52,26 @@ func Load() (*Config, error) {
|
||||
apiPort := getEnvOrDefault("API_PORT", "8080")
|
||||
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
|
||||
cfg := &Config{
|
||||
APIPort: apiPort,
|
||||
APIURL: apiURL,
|
||||
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||
APIPort: apiPort,
|
||||
APIURL: apiURL,
|
||||
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||
MaxOrderCents: loadMaxOrderCents(),
|
||||
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
@@ -78,6 +92,7 @@ func (c *Config) validate() error {
|
||||
{"JWT_SECRET", c.JWTSecret},
|
||||
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
|
||||
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
|
||||
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
|
||||
}
|
||||
|
||||
var missing []string
|
||||
@@ -122,3 +137,48 @@ func getEnvOrDefault(key, defaultValue string) string {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — roughly tracks the
|
||||
// PSD2 TRA exemption ceiling of €500 (≈ $540, FX-dependent), above which Stripe
|
||||
// always requests SCA, so the cap is only meaningful below it. We charge in USD
|
||||
// and the regulatory ceiling is in EUR, so this is an approximate anchor, not an
|
||||
// exact equivalence — $500 trips slightly below the true ceiling, which is the
|
||||
// safe direction. Explicit 0 disables the cap. Garbage or negative values fall
|
||||
// back to the default with a warning so an operator typo cannot silently disable
|
||||
// a fraud control.
|
||||
func loadMaxOrderCents() int {
|
||||
const defaultCap = 50000
|
||||
raw := os.Getenv("MAX_ORDER_CENTS")
|
||||
if raw == "" {
|
||||
return defaultCap
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 {
|
||||
fmt.Printf("WARNING: MAX_ORDER_CENTS=%q invalid, falling back to default %d\n", raw, defaultCap)
|
||||
return defaultCap
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// loadAbandoned3DSThresholdMinutes read ABANDONED_3DS_THRESHOLD_MINUTES.
|
||||
// The abandoned-3DS sweep only flips requires_action rows whose updated_at iconfigs
|
||||
// older than this many minutes. Default 30 min. Values below 1 fall back to
|
||||
// the default so a typo cannot collapse the grace window the TUI polls inside.
|
||||
func loadAbandoned3DSThresholdMinutes() int {
|
||||
const defaultMinutes = 30
|
||||
raw := os.Getenv("ABANDONED_3DS_THRESHOLD_MINUTES")
|
||||
if raw == "" {
|
||||
return defaultMinutes
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 {
|
||||
slog.Warn("invalid ABANDONED_3DS_THRESHOLD_MINUTES, falling back",
|
||||
"raw", raw, "default", defaultMinutes)
|
||||
return defaultMinutes
|
||||
}
|
||||
if n < 10 {
|
||||
slog.Warn("ABANDONED_3DS_THRESHOLD_MINUTES below 10 may race in-flight 3ds challenges",
|
||||
"value", n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
78
pkg/config/config_test.go
Normal file
78
pkg/config/config_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain silences the package's slog warnings so the negative/garbage
|
||||
// env-var cases plus the sub-10 valid-minimum case don't spam test output.
|
||||
// The actual fall-back behavior is still asserted via the case tables.
|
||||
func TestMain(m *testing.M) {
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
code := m.Run()
|
||||
slog.SetDefault(prev)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestLoadMaxOrderCents(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string // "" means unset
|
||||
want int
|
||||
}{
|
||||
{"empty falls back to default", "", 50000},
|
||||
{"explicit zero disables", "0", 0},
|
||||
{"valid override", "30000", 30000},
|
||||
{"negative falls back to default", "-1", 50000},
|
||||
{"garbage falls back to default", "two hundred", 50000},
|
||||
{"empty after trim falls back to default", "", 50000},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.env == "" {
|
||||
t.Setenv("MAX_ORDER_CENTS", "")
|
||||
} else {
|
||||
t.Setenv("MAX_ORDER_CENTS", tc.env)
|
||||
}
|
||||
got := loadMaxOrderCents()
|
||||
if got != tc.want {
|
||||
t.Errorf("loadMaxOrderCents() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAbandoned3DSThresholdMinutes covers the env-parsing surface for
|
||||
// the sweep threshold. Zero and negative fall back to the default (unlike
|
||||
// MaxOrderCents where zero is a deliberate off-switch) because a zero
|
||||
// threshold would sweep every requires_action row on the first tick and
|
||||
// race the customer's in-progress challenge.
|
||||
func TestLoadAbandoned3DSThresholdMinutes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string // "" means unset
|
||||
want int
|
||||
}{
|
||||
{"empty falls back to default", "", 30},
|
||||
{"valid override", "45", 45},
|
||||
{"zero falls back to default", "0", 30},
|
||||
{"negative falls back to default", "-5", 30},
|
||||
{"garbage falls back to default", "half an hour", 30},
|
||||
{"valid minimum (warns)", "1", 1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("ABANDONED_3DS_THRESHOLD_MINUTES", tc.env)
|
||||
got := loadAbandoned3DSThresholdMinutes()
|
||||
if got != tc.want {
|
||||
t.Errorf("loadAbandoned3DSThresholdMinutes() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
3
pkg/config/doc.go
Normal file
3
pkg/config/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package config reads everything out of the environment so secrets and
|
||||
// settings stay out of the source code.
|
||||
package config
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"gorm.io/plugin/opentelemetry/tracing"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -42,6 +44,14 @@ func Connect(dsn string) (*gorm.DB, error) {
|
||||
return
|
||||
}
|
||||
|
||||
if pluginErr := db.Use(tracing.NewPlugin(
|
||||
tracing.WithoutMetrics(),
|
||||
tracing.WithoutQueryVariables(),
|
||||
)); pluginErr != nil {
|
||||
err = fmt.Errorf("failed to register otel plugin: %w", pluginErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Configure connection pool for PostgreSQL.
|
||||
if isPostgres {
|
||||
sqlDB, poolErr := db.DB()
|
||||
@@ -91,7 +101,7 @@ func Close() error {
|
||||
// WARNING: Only use this in tests!
|
||||
func ResetForTesting() {
|
||||
if db != nil {
|
||||
Close()
|
||||
_ = Close()
|
||||
db = nil
|
||||
once = sync.Once{}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestConnect(t *testing.T) {
|
||||
// Use a temporary test database
|
||||
testDB := "test_terminalshop.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
db, err := Connect(testDB)
|
||||
@@ -35,7 +35,7 @@ func TestConnect(t *testing.T) {
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
testDB := "test_migrate.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
db, err := Connect(testDB)
|
||||
@@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) {
|
||||
|
||||
func TestSeed(t *testing.T) {
|
||||
testDB := "test_seed.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
db, err := Connect(testDB)
|
||||
@@ -115,7 +115,7 @@ func TestSeed(t *testing.T) {
|
||||
|
||||
func TestGetDB(t *testing.T) {
|
||||
testDB := "test_getdb.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
// Initialize connection
|
||||
|
||||
3
pkg/database/doc.go
Normal file
3
pkg/database/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package database opens the SQLite connection, runs the migrations, and
|
||||
// drops in some seed data so the shop isn't empty on a fresh boot.
|
||||
package database
|
||||
@@ -3,9 +3,11 @@ package database
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// Migrate runs database migrations
|
||||
@@ -15,18 +17,32 @@ func Migrate(db *gorm.DB) error {
|
||||
// AutoMigrate will create tables, missing columns, and indexes
|
||||
// It will NOT delete unused columns or change column types
|
||||
if err := db.AutoMigrate(
|
||||
&models.Cart{}, // Server-side shipping cart
|
||||
&models.CartItem{}, // Cart line items
|
||||
&models.Coffee{},
|
||||
&models.Cart{}, // Server-side shopping cart
|
||||
&models.CartItem{}, // Cart line items
|
||||
&models.Coffee{}, // Coffees
|
||||
&models.User{}, // SSH key-based authentication
|
||||
&models.Card{}, // Saved payment methods
|
||||
&models.Order{}, // Completed purchases
|
||||
&models.OrderItem{}, // Line items within orders
|
||||
&models.Address{}, // Saved addresses
|
||||
&models.OrderEvent{},
|
||||
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
|
||||
); err != nil {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
if err := backfillCardStorageExpiration(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Database migrations completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillCardStorageExpiration(db *gorm.DB) error {
|
||||
expiresAt := models.CardStorageExpiresAt(time.Now())
|
||||
if err := db.Model(&models.Card{}).Where("storage_expires_at IS NULL").Update("storage_expires_at", expiresAt).Error; err != nil {
|
||||
return fmt.Errorf("card storage ttl backfill failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
67
pkg/database/migrate_test.go
Normal file
67
pkg/database/migrate_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func TestBackfillCardStorageExpiration_FillsNullRowsOnly(t *testing.T) {
|
||||
testDB := "test_backfill.db"
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
db, err := Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:backfill",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA backfill",
|
||||
Name: "Backfill",
|
||||
Email: "backfill@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
legacy := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "0000",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&legacy).Error; err != nil {
|
||||
t.Fatalf("seed legacy: %v", err)
|
||||
}
|
||||
|
||||
fixed := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
preset := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_preset", Last4: "9999",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &fixed,
|
||||
}
|
||||
if err := db.Create(&preset).Error; err != nil {
|
||||
t.Fatalf("ssed preset: %v", err)
|
||||
}
|
||||
if err := backfillCardStorageExpiration(db); err != nil {
|
||||
t.Fatalf("seed preset: %v", err)
|
||||
}
|
||||
|
||||
var reloadedLegacy, reloadedPreset models.Card
|
||||
if err := db.First(&reloadedLegacy, legacy.ID).Error; err != nil {
|
||||
t.Fatalf("reload legacy: %v", err)
|
||||
}
|
||||
if err := db.First(&reloadedPreset, preset.ID).Error; err != nil {
|
||||
t.Fatalf("reload preset: %v", err)
|
||||
}
|
||||
if reloadedLegacy.StorageExpiresAt == nil {
|
||||
t.Fatalf("legacy card storage_expires_at still NULL after backfill")
|
||||
}
|
||||
if reloadedPreset.StorageExpiresAt == nil || !reloadedPreset.StorageExpiresAt.Equal(fixed) {
|
||||
t.Fatalf("preset card storage_expires_at was overwritten: %v", reloadedPreset.StorageExpiresAt)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Seed populates the database with initial data
|
||||
@@ -25,54 +26,60 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Espresso",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 5,
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 350, // $3.50
|
||||
Price: 1800, // $18.00
|
||||
Color: "#8B4513",
|
||||
Description: "Bold and intense coffee with a rich crema",
|
||||
},
|
||||
{
|
||||
Name: "Americano",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 8,
|
||||
BeanType: "Arabica",
|
||||
Price: 400, // $4.00
|
||||
Price: 2200, // $22.00
|
||||
Color: "#A0522D",
|
||||
Description: "Smooth and balanced espresso diluted with hot water",
|
||||
},
|
||||
{
|
||||
Name: "Cappuccino",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 6,
|
||||
BeanType: "Arabica",
|
||||
Price: 450, // $4.50
|
||||
Price: 2200, // $22.00
|
||||
Color: "#C4A574",
|
||||
Description: "Espresso with steamed milk and a thick layer of foam",
|
||||
},
|
||||
{
|
||||
Name: "Latte",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 2,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 500, // $5.00
|
||||
Price: 2500, // $25.00
|
||||
Color: "#D2B48C",
|
||||
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
|
||||
},
|
||||
{
|
||||
Name: "Mocha",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 550, // $5.50
|
||||
Price: 3000, // $30.00
|
||||
Color: "#8B4513",
|
||||
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
|
||||
},
|
||||
{
|
||||
Name: "Macchiato",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 3,
|
||||
BeanType: "Robusta Blend",
|
||||
Price: 425, // $4.25
|
||||
Price: 1800, // $18.00
|
||||
Color: "#DAA520",
|
||||
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
|
||||
},
|
||||
|
||||
@@ -6,21 +6,25 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const cardStorageTTL = 180 * 24 * time.Hour
|
||||
|
||||
// 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:"-"`
|
||||
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:"-"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
StorageExpiresAt *time.Time `gorm:"index" json:"storage_expires_at,omitempty"`
|
||||
|
||||
// Associations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
@@ -30,3 +34,20 @@ type Card struct {
|
||||
func (Card) TableName() string {
|
||||
return "cards"
|
||||
}
|
||||
|
||||
// CardStorageExpiresAt returns the inactivity expiration for a saved card
|
||||
func CardStorageExpiresAt(now time.Time) *time.Time {
|
||||
expiresAt := now.Add(cardStorageTTL)
|
||||
return &expiresAt
|
||||
}
|
||||
|
||||
// InitializeStorageTTL sets the initial retention deadline without marking
|
||||
// the card as used for payment
|
||||
func (c *Card) InitializeStorageTTL(now time.Time) {
|
||||
c.StorageExpiresAt = CardStorageExpiresAt(now)
|
||||
}
|
||||
|
||||
// IsStorageExpired reports whether the saved card retention deadline elapsed
|
||||
func (c *Card) IsStorageExpired(now time.Time) bool {
|
||||
return c.StorageExpiresAt != nil && !c.StorageExpiresAt.After(now)
|
||||
}
|
||||
|
||||
26
pkg/models/card_test.go
Normal file
26
pkg/models/card_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCardStorageTTL(t *testing.T) {
|
||||
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
var card Card
|
||||
card.InitializeStorageTTL(now)
|
||||
|
||||
if card.LastUsedAt != nil {
|
||||
t.Fatalf("InitializeStorageTTL should not mark card as used")
|
||||
}
|
||||
if card.StorageExpiresAt == nil || !card.StorageExpiresAt.Equal(now.Add(cardStorageTTL)) {
|
||||
t.Fatalf("unexpected initial expiration: %v", card.StorageExpiresAt)
|
||||
}
|
||||
if card.IsStorageExpired(now.Add(cardStorageTTL - time.Second)) {
|
||||
t.Fatalf("card expired before ttl elapsed")
|
||||
}
|
||||
if !card.IsStorageExpired(now.Add(cardStorageTTL)) {
|
||||
t.Fatalf("card should expire at ttl boundary")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type Cart struct {
|
||||
CardID *uint `json:"card_id,omitempty"`
|
||||
ShippingCost int `gorm:"default:0" json:"shipping_cost"`
|
||||
ShippingService string `gorm:"size:255" json:"shipping_service,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
|
||||
@@ -11,6 +11,7 @@ type Coffee struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
RoastType string `gorm:"size:100" json:"roast_type"`
|
||||
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
|
||||
Ounces int `json:"ounces"`
|
||||
BeanType string `gorm:"size:100" json:"bean_type"`
|
||||
Price int `gorm:"not null" json:"price"`
|
||||
@@ -26,9 +27,14 @@ func (Coffee) TableName() string {
|
||||
return "coffees"
|
||||
}
|
||||
|
||||
// AccountMenuItems
|
||||
// AccountMenuItems
|
||||
var AccountMenuItems = []string{
|
||||
"active orders",
|
||||
"order history",
|
||||
"addresses",
|
||||
"cards",
|
||||
"spend limit",
|
||||
"privacy",
|
||||
"faq",
|
||||
"about",
|
||||
}
|
||||
|
||||
3
pkg/models/doc.go
Normal file
3
pkg/models/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package models is the shared vocabulary of the app: coffee, carts, orders,
|
||||
// addresses, cards, users, and the other nouns everything passes around.
|
||||
package models
|
||||
@@ -10,41 +10,77 @@ import (
|
||||
type OrderStatus string
|
||||
|
||||
const (
|
||||
OrderStatusPending OrderStatus = "pending"
|
||||
OrderStatusPaid OrderStatus = "paid"
|
||||
OrderStatusShipped OrderStatus = "shipped"
|
||||
OrderStatusDelivered OrderStatus = "delivered"
|
||||
OrderStatusCancelled OrderStatus = "cancelled"
|
||||
OrderStatusRefunded OrderStatus = "refunded"
|
||||
OrderStatusFailed OrderStatus = "failed"
|
||||
OrderStatusPending OrderStatus = "pending"
|
||||
OrderStatusRequiresAction OrderStatus = "requires_action"
|
||||
OrderStatusPaid OrderStatus = "paid"
|
||||
OrderStatusShipped OrderStatus = "shipped"
|
||||
OrderStatusDelivered OrderStatus = "delivered"
|
||||
OrderStatusCancelled OrderStatus = "cancelled"
|
||||
OrderStatusRefunded OrderStatus = "refunded"
|
||||
OrderStatusFailed OrderStatus = "failed"
|
||||
)
|
||||
|
||||
type TrackingStatus string
|
||||
|
||||
const (
|
||||
TrackingStatusUnknown TrackingStatus = ""
|
||||
TrackingStatusPreTransit TrackingStatus = "pre_transit"
|
||||
TrackingStatusTransit TrackingStatus = "transit"
|
||||
TrackingStatusDelivered TrackingStatus = "delivered"
|
||||
TrackingStatusReturned TrackingStatus = "returned"
|
||||
TrackingStatusFailure TrackingStatus = "failure"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"`
|
||||
ShippingCity string `gorm:"size:255" json:"shipping_city"`
|
||||
ShippingState string `gorm:"size:50" json:"shipping_state"`
|
||||
ShippingZip string `gorm:"size:20" json:"shipping_zip"`
|
||||
ShippingCountry string `gorm:"size:2" json:"shipping_country"`
|
||||
ShippingPhone string `gorm:"size:20" json:"shipping_phone"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
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
|
||||
Ephemeral bool `gorm:"default:false" json:"ephemeral"`
|
||||
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"` // The name of the person the shipment is tied to
|
||||
ShippingStreet string `gorm:"size:255" json:"shipping_street"` // The street address the shipment is tied to
|
||||
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` // The alternate street address the shipment is going to if first is valid for whatever reason
|
||||
ShippingCity string `gorm:"size:255" json:"shipping_city"` // The city the shipment is going to
|
||||
ShippingState string `gorm:"size:50" json:"shipping_state"` // The state the shipment is tied to
|
||||
ShippingZip string `gorm:"size:20" json:"shipping_zip"` // The zip code the order is tied to
|
||||
ShippingCountry string `gorm:"size:2" json:"shipping_country"` // The country the shipment is tied to
|
||||
ShippingPhone string `gorm:"size:20" json:"shipping_phone"` // Phone number the shipment is tied to
|
||||
|
||||
// Shipment / tracking - single embedded blob (since its just one warehouse (my house))
|
||||
Carrier string `gorm:"size:100" json:"carrier"`
|
||||
TrackingNumber string `gorm:"size:255;index" json:"tracking_number"`
|
||||
TrackingURL string `gorm:"size:512" json:"tracking_url"`
|
||||
ShippedAt *time.Time `json:"shipped_at"`
|
||||
TrackingStatus TrackingStatus `gorm:"size:50" json:"tracking_status"`
|
||||
TrackingStatusDetails string `gorm:"size:512" json:"tracking_status_details"`
|
||||
TrackingStatusUpdatedAt *time.Time `json:"tracking_status_updated_at"`
|
||||
|
||||
// Shippo label shit, populated by the PurchaseLabel handler
|
||||
ShippoTransactionID *string `gorm:"size:255;uniqueIndex" json:"-"`
|
||||
LabelURL string `gorm:"size:512" json:"-"`
|
||||
LabelCostCents int `json:"-"`
|
||||
|
||||
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
||||
SlackThreadTS *string `gorm:"size:64" json:"-"`
|
||||
|
||||
// LastRefundRequestAt timestamps the most recent customer-initiated refund
|
||||
// request for this order. Used to rate-limit repeat submissions.
|
||||
LastRefundRequestAt *time.Time `json:"-"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
||||
|
||||
// Associations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the Order model.
|
||||
@@ -77,3 +113,41 @@ func (OrderItem) TableName() string {
|
||||
func (o *Order) TotalInDollars() float64 {
|
||||
return float64(o.Total) / 100
|
||||
}
|
||||
|
||||
// IsActive returns whether the order is still active, which is paid but unfulfilled,
|
||||
// or shipped but not yet delivered.
|
||||
func (o *Order) IsActive() bool {
|
||||
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
|
||||
}
|
||||
|
||||
// RefundRequestReasons is the canonical list of customer-facing refund reasons
|
||||
// accepted by both the API and the TUI.
|
||||
var RefundRequestReasons = []string{
|
||||
"Wrong item received",
|
||||
"Item damaged in transit",
|
||||
"Item never arrived",
|
||||
"Quality issue",
|
||||
"Other",
|
||||
}
|
||||
|
||||
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
|
||||
const RefundRequestReasonOther = "Other"
|
||||
|
||||
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
|
||||
func IsValidRefundReason(r string) bool {
|
||||
for _, reason := range RefundRequestReasons {
|
||||
if reason == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanRequestRefund reports whether a customer is allowed to request a refund
|
||||
// for this order. Paid, shipped, and delivered orders qualify; everything else
|
||||
// is either still unpaid or already in a terminal state.
|
||||
func (o *Order) CanRequestRefund() bool {
|
||||
return o.Status == OrderStatusPaid ||
|
||||
o.Status == OrderStatusShipped ||
|
||||
o.Status == OrderStatusDelivered
|
||||
}
|
||||
|
||||
100
pkg/models/order_display.go
Normal file
100
pkg/models/order_display.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package models
|
||||
|
||||
// DisplayKind is a ui agnostic color/severity token.
|
||||
// The TUI (or any other renderer) maps this to a concrete style
|
||||
// the model stays free of UI deps.
|
||||
type DisplayKind string
|
||||
|
||||
const (
|
||||
DisplayKindNeutral DisplayKind = "neutral" // pending, default
|
||||
DisplayKindAccent DisplayKind = "accent" // paid, awaiting fulfillment
|
||||
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
||||
DisplayKindSuccess DisplayKind = "success" // delivered
|
||||
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
||||
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
|
||||
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
|
||||
)
|
||||
|
||||
// refundPending reports whether the order has an open customer refund request
|
||||
// that has not yet been resolved by a terminal status change.
|
||||
func (order *Order) refundPending() bool {
|
||||
if order.LastRefundRequestAt == nil {
|
||||
return false
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (order *Order) DisplayState() string {
|
||||
if order.refundPending() {
|
||||
return "Refund Requested"
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return "Awaiting payment"
|
||||
case OrderStatusRequiresAction:
|
||||
return "Awaiting authentication"
|
||||
case OrderStatusPaid:
|
||||
if order.TrackingNumber == "" {
|
||||
return "Paid * awaiting label"
|
||||
}
|
||||
// !IMPORTANT
|
||||
// Reachable only if UpdateTracking auto-promote is removed later
|
||||
if order.TrackingStatus == TrackingStatusPreTransit {
|
||||
return "Paid * ready to ship"
|
||||
}
|
||||
return "Paid"
|
||||
case OrderStatusShipped:
|
||||
switch order.TrackingStatus {
|
||||
case TrackingStatusTransit:
|
||||
return "In Transit"
|
||||
case TrackingStatusFailure:
|
||||
return "Delivery Issue"
|
||||
case TrackingStatusReturned:
|
||||
return "Returned to Sender"
|
||||
case TrackingStatusDelivered:
|
||||
// Webhook hasn't promoted Status yet, trust tracking
|
||||
return "Delivered"
|
||||
}
|
||||
return "Label Created"
|
||||
case OrderStatusDelivered:
|
||||
return "Delivered"
|
||||
case OrderStatusCancelled:
|
||||
return "Cancelled"
|
||||
case OrderStatusRefunded:
|
||||
return "Refunded"
|
||||
case OrderStatusFailed:
|
||||
return "Payment failed"
|
||||
}
|
||||
return string(order.Status)
|
||||
}
|
||||
|
||||
func (order *Order) DisplayKind() DisplayKind {
|
||||
if order.refundPending() {
|
||||
return DisplayKindRefund
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusRequiresAction:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusPaid:
|
||||
return DisplayKindAccent
|
||||
case OrderStatusShipped:
|
||||
switch order.TrackingStatus {
|
||||
case TrackingStatusFailure, TrackingStatusReturned:
|
||||
return DisplayKindWarning
|
||||
case TrackingStatusDelivered:
|
||||
return DisplayKindSuccess
|
||||
}
|
||||
return DisplayKindBrand
|
||||
case OrderStatusDelivered:
|
||||
return DisplayKindSuccess
|
||||
case OrderStatusCancelled, OrderStatusRefunded, OrderStatusFailed:
|
||||
return DisplayKindError
|
||||
}
|
||||
return DisplayKindNeutral
|
||||
}
|
||||
46
pkg/models/order_display_test.go
Normal file
46
pkg/models/order_display_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOrderDisplayState(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
order Order
|
||||
want string
|
||||
kind DisplayKind
|
||||
}{
|
||||
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
|
||||
{"requires_action", Order{Status: OrderStatusRequiresAction}, "Awaiting authentication", DisplayKindNeutral},
|
||||
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
|
||||
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
|
||||
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},
|
||||
{"shipped no tracking event", Order{Status: OrderStatusShipped}, "Label Created", DisplayKindBrand},
|
||||
{"shipped pre-transit", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusPreTransit}, "Label Created", DisplayKindBrand},
|
||||
{"shipped in transit", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusTransit}, "In Transit", DisplayKindBrand},
|
||||
{"shipped failure", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusFailure}, "Delivery Issue", DisplayKindWarning},
|
||||
{"shipped returned", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusReturned}, "Returned to Sender", DisplayKindWarning},
|
||||
{"shipped tracking-delivered (webhook lag)", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusDelivered}, "Delivered", DisplayKindSuccess},
|
||||
{"delivered", Order{Status: OrderStatusDelivered}, "Delivered", DisplayKindSuccess},
|
||||
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
||||
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
|
||||
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
|
||||
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
|
||||
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.order.DisplayState(); got != tc.want {
|
||||
t.Errorf("DisplayState() = %q, want %q", got, tc.want)
|
||||
}
|
||||
if got := tc.order.DisplayKind(); got != tc.kind {
|
||||
t.Errorf("DisplayKind() = %q, want %q", got, tc.kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
63
pkg/models/order_event.go
Normal file
63
pkg/models/order_event.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OrderEvent struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
OrderID uint `gorm:"not null;index" json:"order_id"`
|
||||
Type string `gorm:"size:100;not null;index" json:"type"`
|
||||
Payload string `gorm:"type:text" json:"payload"`
|
||||
Actor string `gorm:"size:255" json:"actor"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (OrderEvent) TableName() string {
|
||||
return "order_events"
|
||||
}
|
||||
|
||||
func (e *OrderEvent) PayloadMap() map[string]any {
|
||||
if e.Payload == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *OrderEvent) DisplayLabel() (string, bool) {
|
||||
switch e.Type {
|
||||
case "order_paid":
|
||||
return "Paid", true
|
||||
case "label_purchased":
|
||||
return "Label created", true
|
||||
case "order_delivered":
|
||||
return "Delivered", true
|
||||
case "order_refunded":
|
||||
return "Refunded", true
|
||||
case "order_failed":
|
||||
return "Payment failed", true
|
||||
case "tracking_updated":
|
||||
status, _ := e.PayloadMap()["status"].(string)
|
||||
return trackingLabel(status)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func trackingLabel(status string) (string, bool) {
|
||||
switch status {
|
||||
case "transit":
|
||||
return "In transit", true
|
||||
case "failure":
|
||||
return "Delivery issue", true
|
||||
case "returned":
|
||||
return "Returned to sender", true
|
||||
case "pre_transit":
|
||||
return "Label scanned by carrier", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
23
pkg/models/pay_redirect.go
Normal file
23
pkg/models/pay_redirect.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PayRedirect maps a short token to a stripe hosted url with a TTL.
|
||||
// survives api restarts so a customer mid-3Ds authentication still
|
||||
// lands on the bank-hosted challenge page after a deploy or crash
|
||||
type PayRedirect struct {
|
||||
Token string `gorm:"primaryKey;size:64" json:"token"`
|
||||
URL string `gorm:"type:text;not null" json:"url"`
|
||||
Purpose string `gorm:"size:32;not null" json:"purpose"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
RedirectPurposeCheckout3DS = "checkout_3ds"
|
||||
RedirectPurposeAddCard = "add_card"
|
||||
)
|
||||
|
||||
func (PayRedirect) TableName() string {
|
||||
return "pay_redirects"
|
||||
}
|
||||
28
pkg/models/ssh_key.go
Normal file
28
pkg/models/ssh_key.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SSHKey represents one of a users registered SSH public keys.
|
||||
// A user may have multiple keys, any of them grants access.
|
||||
type SSHKey struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Fingerprint string `gorm:"uniqueIndex;size:100;not null" json:"fingerprint"`
|
||||
PublicKey string `gorm:"type:text" json:"-"`
|
||||
Comment string `gorm:"size:255" json:"comment"`
|
||||
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 SSHKey model.
|
||||
func (SSHKey) TableName() string {
|
||||
return "ssh_keys"
|
||||
}
|
||||
@@ -9,16 +9,26 @@ import (
|
||||
// User represents a user authenticated via SSH public key
|
||||
// No passwords - SSH key is the ONLY authentication method (matches terminal.shop exactly)
|
||||
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
|
||||
StripeCustomerID string `gorm:"size:255" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
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
|
||||
StripeCustomerID string `gorm:"size:255" json:"-"`
|
||||
PrivacyMode bool `gorm:"default:false" json:"privacy_mode"`
|
||||
// MaxOrderCents is an optional per-user override of the global MAX_ORDER_CENTS
|
||||
// spend cap. It may raise OR lower the global cap — both directions are
|
||||
// intentional (e.g. a vetted corporate buyer gets a higher ceiling; a flagged
|
||||
// account gets a lower one). States: nil = inherit the global; explicit 0 =
|
||||
// per-user off-switch (same semantics as the global); >0 = custom ceiling. A
|
||||
// negative value is treated as "no override" (falls back to global) with a
|
||||
// warning — see ConvertCart. Operator-set out-of-band; kept out of API payloads.
|
||||
MaxOrderCents *int `json:"-"`
|
||||
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the User model
|
||||
@@ -32,6 +42,8 @@ type PublicUser struct {
|
||||
SSHKeyFingerprint string `json:"ssh_key_fingerprint"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
|
||||
PrivacyMode bool `json:"privacy_mode"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -42,6 +54,8 @@ func (u *User) ToPublic() PublicUser {
|
||||
SSHKeyFingerprint: u.SSHKeyFingerprint,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
SelfLimitCents: u.SelfLimitCents,
|
||||
PrivacyMode: u.PrivacyMode,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
15
pkg/models/user_test.go
Normal file
15
pkg/models/user_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestToPublic_IncludesPrivacyMode guards the converter: PublicUser must carry
|
||||
// the user's PrivacyMode so the TUI can reflect the account toggle's real state.
|
||||
// (Regression: ToPublic originally omitted this field and always reported false.)
|
||||
func TestToPublic_IncludesPrivacyMode(t *testing.T) {
|
||||
for _, want := range []bool{true, false} {
|
||||
u := User{ID: 1, PrivacyMode: want}
|
||||
if got := u.ToPublic().PrivacyMode; got != want {
|
||||
t.Errorf("ToPublic().PrivacyMode = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user