2
0

Version 1

This commit is contained in:
MangoPig
2026-06-23 18:50:37 +01:00
commit 55d9772272
133 changed files with 21515 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
root = "."
tmp_dir = "tmp/dev"
[build]
cmd = "go build -o ./tmp/dev/bin/api ./cmd/api"
bin = "./tmp/dev/bin/api"
include_ext = ["go"]
exclude_dir = ["tmp", "vendor"]
delay = 250
[log]
time = true
+3
View File
@@ -0,0 +1,3 @@
.git
.DS_Store
tmp
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.25.7-alpine AS base
WORKDIR /app
RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates
COPY go.mod go.sum ./
RUN go mod download
FROM base AS development
RUN curl -fsSL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b /usr/local/bin
COPY . .
CMD ["air", "-c", ".air.api.toml"]
FROM base AS builder
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/api ./cmd/api
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates
WORKDIR /app
COPY --from=builder /out/api /usr/local/bin/api
CMD ["/usr/local/bin/api"]
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"context"
"log"
"net/http"
"time"
"royal-pop-backend/internal/config"
"royal-pop-backend/internal/database"
"royal-pop-backend/internal/httpx"
"royal-pop-backend/internal/inventory"
"royal-pop-backend/internal/mailer"
"royal-pop-backend/internal/orders"
)
func main() {
cfg := config.Load()
db, err := database.NewPostgres(cfg.DatabaseURL)
if err != nil {
log.Fatalf("connect postgres: %v", err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.EnsureSchema(ctx); err != nil {
log.Fatalf("ensure postgres schema: %v", err)
}
handler := httpx.NewRouter(httpx.RouterConfig{
Config: cfg,
DB: db,
Orders: orders.NewStore(db),
Stock: inventory.NewStore(db),
Mailer: mailer.NewResendMailer(cfg),
})
log.Printf("royal-pop api listening on %s", cfg.Address())
if err := http.ListenAndServe(cfg.Address(), handler); err != nil {
log.Fatal(err)
}
}
+50
View File
@@ -0,0 +1,50 @@
variable "REGISTRY" {
default = "registry.mangopig.tech"
}
variable "TAG" {
default = "latest"
}
target "_app" {
context = "."
dockerfile = "Dockerfile"
}
target "dev" {
inherits = ["_app"]
target = "development"
tags = ["goko/royal-pop/api:dev"]
}
target "prod" {
inherits = ["_app"]
target = "runtime"
tags = ["goko/royal-pop/api:local-prod"]
}
target "dev-image" {
inherits = ["_app"]
target = "development"
tags = ["${REGISTRY}/goko/royal-pop/api/dev:${TAG}"]
output = ["type=registry"]
}
target "prod-image" {
inherits = ["_app"]
target = "runtime"
tags = ["${REGISTRY}/goko/royal-pop/api/prod:${TAG}"]
output = ["type=registry"]
}
group "local" {
targets = ["dev", "prod"]
}
group "registry" {
targets = ["dev-image", "prod-image"]
}
group "default" {
targets = ["dev"]
}
+18
View File
@@ -0,0 +1,18 @@
module royal-pop-backend
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/jackc/pgx/v5 v5.7.5
github.com/stripe/stripe-go/v83 v83.1.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+34
View File
@@ -0,0 +1,34 @@
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/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
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=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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/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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v83 v83.1.0 h1:h6Wi8+dSUCmIdXDWObs1AirP9tQGWWI/4xP5oE5G6uQ=
github.com/stripe/stripe-go/v83 v83.1.0/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=
+154
View File
@@ -0,0 +1,154 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
AppName string
Environment string
APIPort string
DatabaseURL string
StorefrontURL string
AllowedOrigins []string
ResendAPIKey string
ResendOrderFrom string
ResendForward string
ClientDashboardUser string
ClientDashboardPass string
ClientDashboardSecret string
ClientDashboardSessionTTL time.Duration
StripeSecretKey string
StripeWebhookSecret string
StripePriceID string
StripeCurrency string
RoyalPopUnitAmount int64
RoyalPopRetailAmount int64
ShutdownTimeout time.Duration
}
func Load() *Config {
environment := getEnv("GO_ENV", "development")
storefrontURL := getEnv("API_ALLOWED_ORIGIN", getEnv("STOREFRONT_URL", "http://localhost:4321"))
clientDashboardUser := getEnv("CLIENT_DASHBOARD_USERNAME", "")
clientDashboardPass := getEnv("CLIENT_DASHBOARD_PASSWORD", "")
if strings.EqualFold(environment, "development") {
if strings.TrimSpace(clientDashboardUser) == "" {
clientDashboardUser = "client"
}
if strings.TrimSpace(clientDashboardPass) == "" {
clientDashboardPass = "royalpop-dev"
}
}
return &Config{
AppName: getEnv("APP_NAME", "royal-pop"),
Environment: environment,
APIPort: getEnv("BACKEND_API_PORT", "8081"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://royalpop:royalpop_dev_password@localhost:5432/royalpop?sslmode=disable"),
StorefrontURL: storefrontURL,
AllowedOrigins: splitCSV(getEnv("API_ALLOWED_ORIGINS", storefrontURL)),
ResendAPIKey: getEnv("RESEND_API_KEY", ""),
ResendOrderFrom: getEnv("RESEND_ORDER_FROM", "orders@royal-pop-accessory.com"),
ResendForward: getEnv("RESEND_FORWARD", ""),
ClientDashboardUser: clientDashboardUser,
ClientDashboardPass: clientDashboardPass,
ClientDashboardSecret: getEnv("CLIENT_DASHBOARD_SESSION_SECRET", ""),
ClientDashboardSessionTTL: getDurationEnv("CLIENT_DASHBOARD_SESSION_TTL", 12*time.Hour),
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
StripePriceID: getEnv("STRIPE_PRICE_ID", "price_1TkPnvCpoCwKMSycHiQBPVms"),
StripeCurrency: strings.ToLower(getEnv("STRIPE_CURRENCY", "gbp")),
RoyalPopUnitAmount: getInt64Env("ROYAL_POP_UNIT_AMOUNT", 4999),
RoyalPopRetailAmount: getInt64Env("ROYAL_POP_RETAIL_AMOUNT", 8999),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
}
}
func (c *Config) Address() string {
return fmt.Sprintf(":%s", c.APIPort)
}
func (c *Config) IsDevelopment() bool {
return strings.EqualFold(c.Environment, "development")
}
func (c *Config) ClientDashboardEnabled() bool {
if c == nil {
return false
}
return strings.TrimSpace(c.ClientDashboardUser) != "" && strings.TrimSpace(c.ClientDashboardPass) != ""
}
func (c *Config) ClientDashboardSigningSecret() string {
if c == nil || !c.ClientDashboardEnabled() {
return ""
}
if secret := strings.TrimSpace(c.ClientDashboardSecret); secret != "" {
return secret
}
return strings.Join([]string{c.AppName, c.ClientDashboardUser, c.ClientDashboardPass}, "|")
}
func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists && strings.TrimSpace(value) != "" {
return value
}
return fallback
}
func getDurationEnv(key string, fallback time.Duration) time.Duration {
value, exists := os.LookupEnv(key)
if !exists {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}
func getInt64Env(key string, fallback int64) int64 {
value, exists := os.LookupEnv(key)
if !exists || strings.TrimSpace(value) == "" {
return fallback
}
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return fallback
}
return parsed
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
values = append(values, trimmed)
}
if len(values) == 0 {
return []string{"*"}
}
return values
}
+134
View File
@@ -0,0 +1,134 @@
package database
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
Pool *pgxpool.Pool
}
func NewPostgres(databaseURL string) (*DB, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
config.MaxConns = 10
config.MinConns = 1
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &DB{Pool: pool}, nil
}
func (d *DB) EnsureSchema(ctx context.Context) error {
if d == nil || d.Pool == nil {
return nil
}
statements := []string{
`CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
fulfillment_status TEXT NOT NULL DEFAULT 'pending',
email TEXT NOT NULL,
phone TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
address_line_1 TEXT NOT NULL,
address_line_2 TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL,
region TEXT NOT NULL,
postal_code TEXT NOT NULL,
country TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL,
amount BIGINT NOT NULL,
stripe_price_id TEXT NOT NULL,
stripe_payment_intent_id TEXT NOT NULL UNIQUE,
shipping_carrier TEXT NOT NULL DEFAULT '',
tracking_number TEXT NOT NULL DEFAULT '',
fulfillment_notes TEXT NOT NULL DEFAULT '',
shipped_at TIMESTAMPTZ NULL,
webhook_status TEXT NOT NULL DEFAULT 'awaiting_webhook',
webhook_event_id TEXT NOT NULL DEFAULT '',
webhook_event_type TEXT NOT NULL DEFAULT '',
webhook_message TEXT NOT NULL DEFAULT 'Waiting for Stripe webhook verification.',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS fulfillment_status TEXT NOT NULL DEFAULT 'pending'`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_carrier TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS tracking_number TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS fulfillment_notes TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipped_at TIMESTAMPTZ NULL`,
`CREATE TABLE IF NOT EXISTS order_items (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
item_id TEXT NOT NULL,
style TEXT NOT NULL,
colorway_id TEXT NOT NULL,
finish_id TEXT NOT NULL,
quantity BIGINT NOT NULL,
unit_amount BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id)`,
`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`,
`CREATE INDEX IF NOT EXISTS idx_orders_fulfillment_status ON orders(fulfillment_status)`,
`CREATE INDEX IF NOT EXISTS idx_orders_tracking_number ON orders(tracking_number)`,
`CREATE TABLE IF NOT EXISTS inventory_levels (
id BIGSERIAL PRIMARY KEY,
style TEXT NOT NULL,
colorway_id TEXT NOT NULL,
finish_id TEXT NOT NULL,
quantity_on_hand BIGINT NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(style, colorway_id, finish_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_inventory_levels_colorway ON inventory_levels(colorway_id)`,
}
for _, statement := range statements {
if _, err := d.Pool.Exec(ctx, statement); err != nil {
return err
}
}
return nil
}
func (d *DB) Health(ctx context.Context) error {
if d == nil || d.Pool == nil {
return nil
}
return d.Pool.Ping(ctx)
}
func (d *DB) Close() {
if d == nil || d.Pool == nil {
return
}
d.Pool.Close()
}
+164
View File
@@ -0,0 +1,164 @@
package database
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/jackc/pgx/v5"
)
type ReseedSummary struct {
BackupPath string
OrdersCount int
OrderItemsCount int
InventoryCount int
}
func (d *DB) BackupAndResetAppData(ctx context.Context) (*ReseedSummary, error) {
if d == nil || d.Pool == nil {
return nil, fmt.Errorf("database is not configured")
}
tx, err := d.Pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead})
if err != nil {
return nil, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
ordersStatements, err := collectStatements(ctx, tx, `
SELECT format(
'INSERT INTO orders (id, status, fulfillment_status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, shipping_carrier, tracking_number, fulfillment_notes, shipped_at, webhook_status, webhook_event_id, webhook_event_type, webhook_message, created_at, updated_at) VALUES (%L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L);',
id, status, fulfillment_status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, shipping_carrier, tracking_number, fulfillment_notes, shipped_at, webhook_status, webhook_event_id, webhook_event_type, webhook_message, created_at, updated_at
)
FROM orders
ORDER BY created_at ASC, id ASC
`)
if err != nil {
return nil, err
}
orderItemsStatements, err := collectStatements(ctx, tx, `
SELECT format(
'INSERT INTO order_items (order_id, item_id, style, colorway_id, finish_id, quantity, unit_amount, created_at) VALUES (%L, %L, %L, %L, %L, %L, %L, %L);',
order_id, item_id, style, colorway_id, finish_id, quantity, unit_amount, created_at
)
FROM order_items
ORDER BY created_at ASC, order_id ASC
`)
if err != nil {
return nil, err
}
inventoryStatements, err := collectStatements(ctx, tx, `
SELECT format(
'INSERT INTO inventory_levels (style, colorway_id, finish_id, quantity_on_hand, notes, created_at, updated_at) VALUES (%L, %L, %L, %L, %L, %L, %L);',
style, colorway_id, finish_id, quantity_on_hand, notes, created_at, updated_at
)
FROM inventory_levels
ORDER BY style ASC, colorway_id ASC, finish_id ASC
`)
if err != nil {
return nil, err
}
backupContent := buildReseedBackupSQL(ordersStatements, orderItemsStatements, inventoryStatements)
backupPath, err := writeReseedBackupFile(backupContent)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `TRUNCATE TABLE order_items, orders, inventory_levels RESTART IDENTITY CASCADE`); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
committed = true
return &ReseedSummary{
BackupPath: backupPath,
OrdersCount: len(ordersStatements),
OrderItemsCount: len(orderItemsStatements),
InventoryCount: len(inventoryStatements),
}, nil
}
func collectStatements(ctx context.Context, tx pgx.Tx, query string) ([]string, error) {
rows, err := tx.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
statements := make([]string, 0)
for rows.Next() {
var statement string
if err := rows.Scan(&statement); err != nil {
return nil, err
}
statements = append(statements, statement)
}
if err := rows.Err(); err != nil {
return nil, err
}
return statements, nil
}
func buildReseedBackupSQL(ordersStatements, orderItemsStatements, inventoryStatements []string) []byte {
var buffer bytes.Buffer
timestamp := time.Now().UTC().Format(time.RFC3339)
buffer.WriteString("-- Royal Pop local reseed backup\n")
buffer.WriteString("-- Created at: " + timestamp + "\n")
buffer.WriteString(fmt.Sprintf("-- Orders: %d | Order items: %d | Inventory rows: %d\n\n", len(ordersStatements), len(orderItemsStatements), len(inventoryStatements)))
buffer.WriteString("BEGIN;\n")
buffer.WriteString("TRUNCATE TABLE order_items, orders, inventory_levels RESTART IDENTITY CASCADE;\n\n")
appendSection := func(title string, statements []string) {
buffer.WriteString("-- " + title + "\n")
if len(statements) == 0 {
buffer.WriteString("-- (no rows)\n\n")
return
}
for _, statement := range statements {
buffer.WriteString(statement)
buffer.WriteString("\n")
}
buffer.WriteString("\n")
}
appendSection("orders", ordersStatements)
appendSection("order_items", orderItemsStatements)
appendSection("inventory_levels", inventoryStatements)
buffer.WriteString("COMMIT;\n")
return buffer.Bytes()
}
func writeReseedBackupFile(content []byte) (string, error) {
backupDir := filepath.Join("Backups", "reseed")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", err
}
filename := fmt.Sprintf("royal-pop-reseed-%s.sql", time.Now().UTC().Format("20060102-150405"))
path := filepath.Join(backupDir, filename)
if err := os.WriteFile(path, content, 0o600); err != nil {
return "", err
}
return path, nil
}
+446
View File
@@ -0,0 +1,446 @@
package httpx
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v83"
"github.com/stripe/stripe-go/v83/paymentintent"
stripeprice "github.com/stripe/stripe-go/v83/price"
"royal-pop-backend/internal/config"
"royal-pop-backend/internal/orders"
)
type createPaymentIntentRequest struct {
Items []checkoutItem `json:"items"`
PaymentMethod string `json:"paymentMethod"`
Customer checkoutCustomerDetails `json:"customer"`
}
type checkoutItem struct {
ID string `json:"id"`
Style string `json:"style"`
ColorwayID string `json:"colorwayId"`
FinishID string `json:"finishId"`
Quantity int64 `json:"quantity,omitempty"`
}
type checkoutCustomerDetails struct {
Email string `json:"email"`
Phone string `json:"phone"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
AddressLine1 string `json:"addressLine1"`
AddressLine2 string `json:"addressLine2"`
City string `json:"city"`
Region string `json:"region"`
PostalCode string `json:"postalCode"`
Country string `json:"country"`
Notes string `json:"notes"`
}
type storefrontPricing struct {
PriceID string
Currency string
UnitAmount int64
RetailAmount int64
}
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
func handleCreatePaymentIntent(cfg *config.Config, orderStore *orders.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.StripeSecretKey == "" {
WriteError(w, http.StatusInternalServerError, "stripe_not_configured", "Missing STRIPE_SECRET_KEY for backend Stripe calls.")
return
}
var request createPaymentIntentRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_request", "The payment intent payload could not be parsed.")
return
}
itemCount := countItems(request.Items)
if itemCount <= 0 {
WriteError(w, http.StatusBadRequest, "empty_cart", "Add at least one Royal Pop kit before creating a payment intent.")
return
}
customerDetails, err := validateCustomerDetails(request.Customer)
if err != nil {
WriteError(w, http.StatusBadRequest, "invalid_customer_details", err.Error())
return
}
pricing := resolveStorefrontPricing(cfg)
stripe.Key = cfg.StripeSecretKey
amount := itemCount * pricing.UnitAmount
orderID := orders.NewOrderID()
metadata := buildPaymentIntentMetadata(cfg, request, itemCount, amount, orderID)
params := &stripe.PaymentIntentParams{
Amount: stripe.Int64(amount),
Currency: stripe.String(pricing.Currency),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
Enabled: stripe.Bool(true),
AllowRedirects: stripe.String("never"),
},
Description: stripe.String(fmt.Sprintf("Royal Pop preorder (%d kit%s)", itemCount, pluralize(itemCount))),
}
for key, value := range metadata {
params.AddMetadata(key, value)
}
intent, err := paymentintent.New(params)
if err != nil {
WriteError(w, http.StatusBadGateway, "stripe_payment_intent_failed", err.Error())
return
}
orderRecord, err := orderStore.CreatePendingOrder(r.Context(), orders.CreateOrderInput{
OrderID: orderID,
Customer: orders.CustomerDetails{
Email: customerDetails.Email,
Phone: customerDetails.Phone,
FirstName: customerDetails.FirstName,
LastName: customerDetails.LastName,
AddressLine1: customerDetails.AddressLine1,
AddressLine2: customerDetails.AddressLine2,
City: customerDetails.City,
Region: customerDetails.Region,
PostalCode: customerDetails.PostalCode,
Country: customerDetails.Country,
Notes: customerDetails.Notes,
},
Items: buildOrderItems(request.Items, pricing.UnitAmount),
Currency: pricing.Currency,
Amount: amount,
StripePriceID: cfg.StripePriceID,
StripePaymentIntentID: intent.ID,
})
if err != nil {
WriteError(w, http.StatusInternalServerError, "order_persist_failed", "The payment session was created, but the order could not be saved. Please try again.")
return
}
paymentIntentStates.upsertCreated(intent.ID)
WriteJSON(w, http.StatusCreated, map[string]any{
"data": map[string]any{
"orderId": orderRecord.ID,
"paymentIntentId": intent.ID,
"clientSecret": intent.ClientSecret,
"amount": intent.Amount,
"currency": intent.Currency,
"itemCount": itemCount,
"priceId": cfg.StripePriceID,
"unitAmount": pricing.UnitAmount,
"submitEnabled": true,
"message": paymentIntentCreatedMessage(cfg),
},
})
}
}
func handleGetStorefrontPricing(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
pricing := resolveStorefrontPricing(cfg)
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"priceId": pricing.PriceID,
"currency": pricing.Currency,
"unitAmount": pricing.UnitAmount,
"retailAmount": pricing.RetailAmount,
},
})
}
}
func handleGetPaymentIntentStatus(cfg *config.Config, orderStore *orders.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.StripeSecretKey == "" {
WriteError(w, http.StatusInternalServerError, "stripe_not_configured", "Missing STRIPE_SECRET_KEY for backend Stripe calls.")
return
}
paymentIntentID := strings.TrimSpace(chi.URLParam(r, "paymentIntentID"))
if paymentIntentID == "" {
WriteError(w, http.StatusBadRequest, "missing_payment_intent_id", "The payment intent ID is required.")
return
}
stripe.Key = cfg.StripeSecretKey
intent, err := paymentintent.Get(paymentIntentID, nil)
if err != nil {
WriteError(w, http.StatusBadGateway, "stripe_payment_intent_lookup_failed", err.Error())
return
}
verification, exists := paymentIntentStates.get(intent.ID)
orderRecord, orderErr := orderStore.GetByPaymentIntentID(r.Context(), intent.ID)
if orderErr == nil {
verification = paymentIntentVerification{
PaymentIntentID: orderRecord.StripePaymentIntentID,
WebhookState: orderRecord.WebhookStatus,
WebhookEventID: orderRecord.WebhookEventID,
WebhookEventType: orderRecord.WebhookEventType,
Message: orderRecord.WebhookMessage,
Verified: orderRecord.Status == orders.StatusPaid,
}
exists = true
}
if !exists {
verification = paymentIntentVerification{
PaymentIntentID: intent.ID,
WebhookState: "awaiting_webhook",
Message: "Waiting for Stripe webhook verification.",
}
}
message := verification.Message
if message == "" {
message = defaultStatusMessage(intent.Status, verification.Verified)
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"orderId": orderIDFromOrder(orderRecord),
"orderStatus": orderStatusFromOrder(orderRecord),
"order": serializeOrder(orderRecord),
"paymentIntentId": intent.ID,
"stripeStatus": string(intent.Status),
"amount": intent.Amount,
"currency": intent.Currency,
"webhookStatus": verification.WebhookState,
"webhookEventId": verification.WebhookEventID,
"webhookEventType": verification.WebhookEventType,
"verified": verification.Verified,
"message": message,
},
})
}
}
func countItems(items []checkoutItem) int64 {
var total int64
for _, item := range items {
quantity := item.Quantity
if quantity <= 0 {
quantity = 1
}
total += quantity
}
return total
}
func buildPaymentIntentMetadata(cfg *config.Config, request createPaymentIntentRequest, itemCount int64, amount int64, orderID string) map[string]string {
pricing := resolveStorefrontPricing(cfg)
colorways := make([]string, 0, len(request.Items))
styles := make([]string, 0, len(request.Items))
for _, item := range request.Items {
if item.ColorwayID != "" {
colorways = append(colorways, item.ColorwayID)
}
if item.Style != "" {
styles = append(styles, item.Style)
}
}
sort.Strings(colorways)
sort.Strings(styles)
return map[string]string{
"order_id": orderID,
"source": "royal-pop-storefront",
"stripe_price_id": cfg.StripePriceID,
"payment_method_ui": strings.TrimSpace(request.PaymentMethod),
"item_count": fmt.Sprintf("%d", itemCount),
"unit_amount": fmt.Sprintf("%d", pricing.UnitAmount),
"amount_total": fmt.Sprintf("%d", amount),
"colorways": strings.Join(colorways, ","),
"styles": strings.Join(styles, ","),
}
}
func resolveStorefrontPricing(cfg *config.Config) storefrontPricing {
pricing := storefrontPricing{
PriceID: strings.TrimSpace(cfg.StripePriceID),
Currency: strings.ToLower(strings.TrimSpace(cfg.StripeCurrency)),
UnitAmount: cfg.RoyalPopUnitAmount,
RetailAmount: cfg.RoyalPopRetailAmount,
}
if pricing.Currency == "" {
pricing.Currency = "gbp"
}
if strings.TrimSpace(cfg.StripeSecretKey) == "" || pricing.PriceID == "" {
return pricing
}
stripe.Key = cfg.StripeSecretKey
priceRecord, err := stripeprice.Get(pricing.PriceID, nil)
if err != nil || priceRecord == nil {
return pricing
}
if priceRecord.UnitAmount > 0 {
pricing.UnitAmount = priceRecord.UnitAmount
}
if currency := strings.ToLower(string(priceRecord.Currency)); currency != "" {
pricing.Currency = currency
}
for _, key := range []string{"retail_amount", "compare_at_amount", "compareAtAmount"} {
if raw := strings.TrimSpace(priceRecord.Metadata[key]); raw != "" {
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 {
pricing.RetailAmount = parsed
break
}
}
}
return pricing
}
func buildOrderItems(items []checkoutItem, unitAmount int64) []orders.LineItem {
result := make([]orders.LineItem, 0, len(items))
for _, item := range items {
quantity := item.Quantity
if quantity <= 0 {
quantity = 1
}
result = append(result, orders.LineItem{
ItemID: strings.TrimSpace(item.ID),
Style: strings.TrimSpace(item.Style),
ColorwayID: strings.TrimSpace(item.ColorwayID),
FinishID: strings.TrimSpace(item.FinishID),
Quantity: quantity,
UnitAmount: unitAmount,
})
}
return result
}
func validateCustomerDetails(input checkoutCustomerDetails) (checkoutCustomerDetails, error) {
normalized := checkoutCustomerDetails{
Email: strings.TrimSpace(input.Email),
Phone: strings.TrimSpace(input.Phone),
FirstName: strings.TrimSpace(input.FirstName),
LastName: strings.TrimSpace(input.LastName),
AddressLine1: strings.TrimSpace(input.AddressLine1),
AddressLine2: strings.TrimSpace(input.AddressLine2),
City: strings.TrimSpace(input.City),
Region: strings.TrimSpace(input.Region),
PostalCode: strings.TrimSpace(input.PostalCode),
Country: strings.TrimSpace(input.Country),
Notes: strings.TrimSpace(input.Notes),
}
if !emailPattern.MatchString(normalized.Email) {
return normalized, fmt.Errorf("Enter a valid email address before continuing to payment.")
}
if len(normalized.Phone) < 7 {
return normalized, fmt.Errorf("Enter a valid phone number before continuing to payment.")
}
if len(normalized.FirstName) < 2 || len(normalized.LastName) < 2 {
return normalized, fmt.Errorf("Enter the full shipping name before continuing to payment.")
}
if len(normalized.AddressLine1) < 5 || len(normalized.City) < 2 || len(normalized.Region) < 2 || len(normalized.PostalCode) < 3 || len(normalized.Country) < 2 {
return normalized, fmt.Errorf("Complete the full shipping address before continuing to payment.")
}
return normalized, nil
}
func orderIDFromOrder(order *orders.Order) string {
if order == nil {
return ""
}
return order.ID
}
func orderStatusFromOrder(order *orders.Order) string {
if order == nil {
return ""
}
return order.Status
}
func pluralize(value int64) string {
if value == 1 {
return ""
}
return "s"
}
func paymentIntentCreatedMessage(cfg *config.Config) string {
if cfg.IsDevelopment() {
return "Payment session created. Complete the payment form, then wait for Stripe webhook verification to confirm the order."
}
return "Payment session created. Complete the payment form, then wait for Stripe webhook verification to confirm the order."
}
func defaultStatusMessage(status stripe.PaymentIntentStatus, verified bool) string {
if verified {
return "Stripe confirmed your payment and updated the order record."
}
switch status {
case stripe.PaymentIntentStatusSucceeded:
return "Stripe reports this payment as succeeded, but the backend is still waiting for the webhook confirmation."
case stripe.PaymentIntentStatusProcessing:
return "Stripe is still processing this payment intent."
case stripe.PaymentIntentStatusCanceled:
return "Stripe marked this payment intent as canceled."
case stripe.PaymentIntentStatusRequiresPaymentMethod:
return "Stripe needs a valid payment method before this payment can complete."
case stripe.PaymentIntentStatusRequiresAction:
return "Stripe requires additional customer action before this payment can complete."
default:
return "Waiting for the next Stripe payment update."
}
}
func serializeOrder(order *orders.Order) map[string]any {
if order == nil {
return nil
}
items := make([]map[string]any, 0, len(order.Items))
for _, item := range order.Items {
items = append(items, map[string]any{
"itemId": item.ItemID,
"style": item.Style,
"colorwayId": item.ColorwayID,
"finishId": item.FinishID,
"quantity": item.Quantity,
"unitAmount": item.UnitAmount,
})
}
return map[string]any{
"id": order.ID,
"status": order.Status,
"currency": order.Currency,
"amount": order.Amount,
"webhookStatus": order.WebhookStatus,
"items": items,
}
}
@@ -0,0 +1,755 @@
package httpx
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"royal-pop-backend/internal/config"
"royal-pop-backend/internal/database"
"royal-pop-backend/internal/inventory"
"royal-pop-backend/internal/mailer"
"royal-pop-backend/internal/orders"
)
const clientSessionCookieName = "royal_pop_client_session"
type clientLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type clientStockUpdateRequest struct {
Style string `json:"style"`
ColorwayID string `json:"colorwayId"`
FinishID string `json:"finishId"`
QuantityOnHand int64 `json:"quantityOnHand"`
Notes string `json:"notes"`
}
type clientOrderUpdateRequest struct {
FulfillmentStatus string `json:"fulfillmentStatus"`
ShippingCarrier string `json:"shippingCarrier"`
TrackingNumber string `json:"trackingNumber"`
FulfillmentNotes string `json:"fulfillmentNotes"`
ClearShippedAt bool `json:"clearShippedAt"`
MarkShippedAtNow bool `json:"markShippedAtNow"`
}
func handleClientReseed(db *database.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if db == nil {
WriteError(w, http.StatusServiceUnavailable, "reseed_unavailable", "Database reseed is not configured yet.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
summary, err := db.BackupAndResetAppData(ctx)
if err != nil {
WriteError(w, http.StatusInternalServerError, "reseed_failed", "The database backup or reset could not be completed.")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"backupPath": summary.BackupPath,
"ordersCount": summary.OrdersCount,
"orderItemsCount": summary.OrderItemsCount,
"inventoryCount": summary.InventoryCount,
"message": "Backup created and the local database has been reset to an empty state.",
},
})
}
}
func handleListPublicInventory(stockStore *inventory.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if stockStore == nil {
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"inventory": []map[string]any{},
},
})
return
}
levels, err := stockStore.List(r.Context())
if err != nil {
WriteError(w, http.StatusInternalServerError, "inventory_list_failed", "The storefront could not load inventory availability.")
return
}
serialized := make([]map[string]any, 0, len(levels))
for _, level := range levels {
serialized = append(serialized, map[string]any{
"style": level.Style,
"colorwayId": level.ColorwayID,
"finishId": level.FinishID,
"quantityOnHand": level.QuantityOnHand,
"updatedAt": level.UpdatedAt.Format(time.RFC3339),
})
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"inventory": serialized,
},
})
}
}
func handleGetClientSession(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !cfg.ClientDashboardEnabled() {
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"authenticated": false,
"configured": false,
},
})
return
}
username, expiresAt, ok := validateClientSessionCookie(cfg, r)
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"authenticated": ok,
"configured": true,
"username": username,
"expiresAt": expiresAt,
},
})
}
}
func handleClientLogin(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !cfg.ClientDashboardEnabled() {
WriteError(w, http.StatusServiceUnavailable, "client_dashboard_not_configured", "Client dashboard credentials are not configured yet.")
return
}
var request clientLoginRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid username and password.")
return
}
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(request.Username)), []byte(cfg.ClientDashboardUser)) != 1 ||
subtle.ConstantTimeCompare([]byte(request.Password), []byte(cfg.ClientDashboardPass)) != 1 {
WriteError(w, http.StatusUnauthorized, "invalid_client_credentials", "The client dashboard username or password is incorrect.")
return
}
expiresAt := time.Now().Add(cfg.ClientDashboardSessionTTL)
setClientSessionCookie(w, cfg, cfg.ClientDashboardUser, expiresAt)
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"authenticated": true,
"configured": true,
"username": cfg.ClientDashboardUser,
"expiresAt": expiresAt.Format(time.RFC3339),
},
})
}
}
func handleClientLogout(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
clearClientSessionCookie(w, cfg)
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"authenticated": false,
},
})
}
}
func requireClientSession(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !cfg.ClientDashboardEnabled() {
WriteError(w, http.StatusServiceUnavailable, "client_dashboard_not_configured", "Client dashboard credentials are not configured yet.")
return
}
if _, _, ok := validateClientSessionCookie(cfg, r); !ok {
WriteError(w, http.StatusUnauthorized, "client_dashboard_auth_required", "Please sign in to the client dashboard first.")
return
}
next.ServeHTTP(w, r)
})
}
}
func handleListClientOrders(orderStore *orders.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if orderStore == nil {
WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.")
return
}
limit := 25
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err == nil {
limit = parsed
}
}
search := strings.TrimSpace(r.URL.Query().Get("search"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
ordersList, err := orderStore.ListForDashboard(r.Context(), orders.ListFilter{
Limit: limit,
Search: search,
FulfillmentStatus: status,
})
if err != nil {
WriteError(w, http.StatusInternalServerError, "orders_list_failed", "The client dashboard could not load recent orders.")
return
}
serialized := make([]map[string]any, 0, len(ordersList))
for _, order := range ordersList {
serialized = append(serialized, serializeClientOrderSummary(order))
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"orders": serialized,
},
})
}
}
func handleGetClientOrder(orderStore *orders.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if orderStore == nil {
WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.")
return
}
orderID := strings.TrimSpace(chi.URLParam(r, "orderID"))
if orderID == "" {
WriteError(w, http.StatusBadRequest, "invalid_order_id", "Provide a valid order ID.")
return
}
order, err := orderStore.GetByID(r.Context(), orderID)
if err != nil {
if err == orders.ErrOrderNotFound {
WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.")
return
}
WriteError(w, http.StatusInternalServerError, "order_load_failed", "The client dashboard could not load this order.")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"order": serializeClientOrderDetail(order),
},
})
}
}
func handleUpdateClientOrder(orderStore *orders.Store, fulfillmentMailer *mailer.ResendMailer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if orderStore == nil {
WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.")
return
}
orderID := strings.TrimSpace(chi.URLParam(r, "orderID"))
if orderID == "" {
WriteError(w, http.StatusBadRequest, "invalid_order_id", "Provide a valid order ID.")
return
}
existingOrder, err := orderStore.GetByID(r.Context(), orderID)
if err != nil {
if err == orders.ErrOrderNotFound {
WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.")
return
}
WriteError(w, http.StatusInternalServerError, "order_load_failed", "The client dashboard could not load this order.")
return
}
var request clientOrderUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid order update payload.")
return
}
updatedOrder, err := orderStore.UpdateFulfillment(r.Context(), orders.UpdateFulfillmentInput{
OrderID: orderID,
FulfillmentStatus: request.FulfillmentStatus,
ShippingCarrier: request.ShippingCarrier,
TrackingNumber: request.TrackingNumber,
FulfillmentNotes: request.FulfillmentNotes,
ClearShippedAt: request.ClearShippedAt,
MarkShippedAtNow: request.MarkShippedAtNow,
})
if err != nil {
switch err {
case orders.ErrOrderNotFound:
WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.")
return
default:
if strings.Contains(err.Error(), "invalid fulfillment status") {
WriteError(w, http.StatusBadRequest, "invalid_fulfillment_status", "Choose a valid fulfillment status.")
return
}
WriteError(w, http.StatusInternalServerError, "order_update_failed", "The order update could not be saved.")
return
}
}
shipmentEmail := map[string]any{
"status": "skipped",
"message": "Fulfilment email was not sent.",
}
if fulfillmentMailer == nil || !fulfillmentMailer.Enabled() {
shipmentEmail["reason"] = "not_configured"
shipmentEmail["message"] = "Fulfilment email is not configured."
} else if shouldSendShipmentEmail(existingOrder, updatedOrder) {
if err := fulfillmentMailer.SendOrderShipped(r.Context(), updatedOrder); err != nil {
log.Printf("client order shipment email failed for %s: %v", updatedOrder.ID, err)
shipmentEmail["status"] = "failed"
shipmentEmail["message"] = "The order was updated, but the shipment email could not be sent."
shipmentEmail["reason"] = "send_failed"
} else {
shipmentEmail["status"] = "sent"
shipmentEmail["message"] = "Shipment email sent to the customer."
}
} else if shouldSendCancellationEmail(existingOrder, updatedOrder) {
if err := fulfillmentMailer.SendOrderCancelled(r.Context(), updatedOrder); err != nil {
log.Printf("client order cancellation email failed for %s: %v", updatedOrder.ID, err)
shipmentEmail["status"] = "failed"
shipmentEmail["message"] = "The order was updated, but the cancellation email could not be sent."
shipmentEmail["reason"] = "send_failed"
} else {
shipmentEmail["status"] = "sent"
shipmentEmail["message"] = "Cancellation email sent to the customer."
}
} else if shouldSendDeliveredEmail(existingOrder, updatedOrder) {
if err := fulfillmentMailer.SendOrderDelivered(r.Context(), updatedOrder); err != nil {
log.Printf("client order delivered email failed for %s: %v", updatedOrder.ID, err)
shipmentEmail["status"] = "failed"
shipmentEmail["message"] = "The order was updated, but the delivered email could not be sent."
shipmentEmail["reason"] = "send_failed"
} else {
shipmentEmail["status"] = "sent"
shipmentEmail["message"] = "Delivered email sent to the customer."
}
} else {
shipmentEmail["reason"] = shipmentEmailSkipReason(existingOrder, updatedOrder)
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"order": serializeClientOrderDetail(updatedOrder),
"shipmentEmail": shipmentEmail,
},
})
}
}
func shouldSendShipmentEmail(previousOrder, updatedOrder *orders.Order) bool {
if previousOrder == nil || updatedOrder == nil {
return false
}
if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentShipped) {
return false
}
if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentShipped) {
return false
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return false
}
if strings.TrimSpace(updatedOrder.ShippingCarrier) == "" || strings.TrimSpace(updatedOrder.TrackingNumber) == "" {
return false
}
return true
}
func shouldSendCancellationEmail(previousOrder, updatedOrder *orders.Order) bool {
if previousOrder == nil || updatedOrder == nil {
return false
}
if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentCancelled) {
return false
}
if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentCancelled) {
return false
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return false
}
return true
}
func shouldSendDeliveredEmail(previousOrder, updatedOrder *orders.Order) bool {
if previousOrder == nil || updatedOrder == nil {
return false
}
if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentDelivered) {
return false
}
if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentDelivered) {
return false
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return false
}
return true
}
func shipmentEmailSkipReason(previousOrder, updatedOrder *orders.Order) string {
if updatedOrder == nil {
return "order_unavailable"
}
if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentShipped) {
if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentShipped) {
return "already_shipped"
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return "missing_email"
}
if strings.TrimSpace(updatedOrder.ShippingCarrier) == "" || strings.TrimSpace(updatedOrder.TrackingNumber) == "" {
return "missing_tracking"
}
return "not_applicable"
}
if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentCancelled) {
if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentCancelled) {
return "already_cancelled"
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return "missing_email"
}
return "not_applicable"
}
if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentDelivered) {
if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentDelivered) {
return "already_delivered"
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return "missing_email"
}
return "not_applicable"
}
return "status_not_supported"
}
func handleListClientStock(stockStore *inventory.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if stockStore == nil {
WriteError(w, http.StatusServiceUnavailable, "stock_unavailable", "Stock storage is not configured yet.")
return
}
levels, err := stockStore.List(r.Context())
if err != nil {
WriteError(w, http.StatusInternalServerError, "stock_list_failed", "The client dashboard could not load stock levels.")
return
}
serialized := make([]map[string]any, 0, len(levels))
for _, level := range levels {
serialized = append(serialized, map[string]any{
"style": level.Style,
"colorwayId": level.ColorwayID,
"finishId": level.FinishID,
"quantityOnHand": level.QuantityOnHand,
"notes": level.Notes,
"createdAt": level.CreatedAt.Format(time.RFC3339),
"updatedAt": level.UpdatedAt.Format(time.RFC3339),
})
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"stock": serialized,
},
})
}
}
func handleUpsertClientStock(stockStore *inventory.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if stockStore == nil {
WriteError(w, http.StatusServiceUnavailable, "stock_unavailable", "Stock storage is not configured yet.")
return
}
var request clientStockUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid stock update payload.")
return
}
request.Style = strings.TrimSpace(strings.ToUpper(request.Style))
request.ColorwayID = strings.TrimSpace(request.ColorwayID)
request.FinishID = strings.TrimSpace(request.FinishID)
request.Notes = strings.TrimSpace(request.Notes)
if request.Style != "A" && request.Style != "B" {
WriteError(w, http.StatusBadRequest, "invalid_style", "Stock entries must use style A or B.")
return
}
if request.ColorwayID == "" || request.FinishID == "" {
WriteError(w, http.StatusBadRequest, "invalid_stock_key", "Stock entries need a colorway and finish.")
return
}
if request.QuantityOnHand < 0 {
WriteError(w, http.StatusBadRequest, "invalid_quantity", "Stock quantity cannot be negative.")
return
}
level, err := stockStore.Upsert(r.Context(), inventory.UpsertInput{
Style: request.Style,
ColorwayID: request.ColorwayID,
FinishID: request.FinishID,
QuantityOnHand: request.QuantityOnHand,
Notes: request.Notes,
})
if err != nil {
WriteError(w, http.StatusInternalServerError, "stock_save_failed", "The stock level could not be saved.")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"stock": map[string]any{
"style": level.Style,
"colorwayId": level.ColorwayID,
"finishId": level.FinishID,
"quantityOnHand": level.QuantityOnHand,
"notes": level.Notes,
"createdAt": level.CreatedAt.Format(time.RFC3339),
"updatedAt": level.UpdatedAt.Format(time.RFC3339),
},
},
})
}
}
func validateClientSessionCookie(cfg *config.Config, r *http.Request) (string, string, bool) {
if cfg == nil || r == nil || !cfg.ClientDashboardEnabled() {
return "", "", false
}
cookie, err := r.Cookie(clientSessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return "", "", false
}
decoded, err := base64.RawURLEncoding.DecodeString(cookie.Value)
if err != nil {
return "", "", false
}
parts := strings.Split(string(decoded), ".")
if len(parts) != 3 {
return "", "", false
}
username := parts[0]
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", "", false
}
expiresAt := time.Unix(expiresUnix, 0).UTC()
if time.Now().After(expiresAt) {
return "", "", false
}
expectedSignature := signClientSession(cfg.ClientDashboardSigningSecret(), username, expiresUnix)
if subtle.ConstantTimeCompare([]byte(parts[2]), []byte(expectedSignature)) != 1 {
return "", "", false
}
if subtle.ConstantTimeCompare([]byte(username), []byte(cfg.ClientDashboardUser)) != 1 {
return "", "", false
}
return username, expiresAt.Format(time.RFC3339), true
}
func setClientSessionCookie(w http.ResponseWriter, cfg *config.Config, username string, expiresAt time.Time) {
expiresUnix := expiresAt.UTC().Unix()
token := fmt.Sprintf("%s.%d.%s", username, expiresUnix, signClientSession(cfg.ClientDashboardSigningSecret(), username, expiresUnix))
http.SetCookie(w, &http.Cookie{
Name: clientSessionCookieName,
Value: base64.RawURLEncoding.EncodeToString([]byte(token)),
Path: "/",
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !cfg.IsDevelopment(),
})
}
func clearClientSessionCookie(w http.ResponseWriter, cfg *config.Config) {
http.SetCookie(w, &http.Cookie{
Name: clientSessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !cfg.IsDevelopment(),
})
}
func signClientSession(secret, username string, expiresUnix int64) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(fmt.Sprintf("%s|%d", username, expiresUnix)))
return hex.EncodeToString(mac.Sum(nil))
}
func serializeClientOrderSummary(order orders.Order) map[string]any {
itemCount := int64(0)
items := make([]map[string]any, 0, len(order.Items))
for _, item := range order.Items {
itemCount += item.Quantity
items = append(items, map[string]any{
"style": item.Style,
"colorwayId": item.ColorwayID,
"finishId": item.FinishID,
"quantity": item.Quantity,
"unitAmount": item.UnitAmount,
})
}
return map[string]any{
"id": order.ID,
"status": order.Status,
"fulfillmentStatus": order.FulfillmentStatus,
"email": order.Email,
"phone": order.Phone,
"firstName": order.FirstName,
"lastName": order.LastName,
"city": order.City,
"region": order.Region,
"postalCode": order.PostalCode,
"country": order.Country,
"notes": order.Notes,
"currency": order.Currency,
"amount": order.Amount,
"shippingCarrier": order.ShippingCarrier,
"trackingNumber": order.TrackingNumber,
"webhookStatus": order.WebhookStatus,
"webhookMessage": order.WebhookMessage,
"createdAt": order.CreatedAt.Format(time.RFC3339),
"updatedAt": order.UpdatedAt.Format(time.RFC3339),
"shippedAt": formatOptionalTime(order.ShippedAt),
"itemCount": itemCount,
"items": items,
}
}
func serializeClientOrderDetail(order *orders.Order) map[string]any {
if order == nil {
return map[string]any{}
}
items := make([]map[string]any, 0, len(order.Items))
for _, item := range order.Items {
items = append(items, map[string]any{
"itemId": item.ItemID,
"style": item.Style,
"colorwayId": item.ColorwayID,
"finishId": item.FinishID,
"quantity": item.Quantity,
"unitAmount": item.UnitAmount,
})
}
return map[string]any{
"id": order.ID,
"status": order.Status,
"fulfillmentStatus": order.FulfillmentStatus,
"email": order.Email,
"phone": order.Phone,
"firstName": order.FirstName,
"lastName": order.LastName,
"addressLine1": order.AddressLine1,
"addressLine2": order.AddressLine2,
"city": order.City,
"region": order.Region,
"postalCode": order.PostalCode,
"country": order.Country,
"notes": order.Notes,
"currency": order.Currency,
"amount": order.Amount,
"shippingCarrier": order.ShippingCarrier,
"trackingNumber": order.TrackingNumber,
"fulfillmentNotes": order.FulfillmentNotes,
"webhookStatus": order.WebhookStatus,
"webhookEventId": order.WebhookEventID,
"webhookEventType": order.WebhookEventType,
"webhookMessage": order.WebhookMessage,
"stripePaymentIntentId": order.StripePaymentIntentID,
"createdAt": order.CreatedAt.Format(time.RFC3339),
"updatedAt": order.UpdatedAt.Format(time.RFC3339),
"shippedAt": formatOptionalTime(order.ShippedAt),
"items": items,
}
}
func formatOptionalTime(value *time.Time) any {
if value == nil {
return nil
}
return value.UTC().Format(time.RFC3339)
}
+105
View File
@@ -0,0 +1,105 @@
package httpx
import (
"strings"
"sync"
"time"
)
type paymentIntentVerification struct {
PaymentIntentID string `json:"paymentIntentId"`
WebhookState string `json:"webhookState"`
WebhookEventID string `json:"webhookEventId,omitempty"`
WebhookEventType string `json:"webhookEventType,omitempty"`
Message string `json:"message"`
Verified bool `json:"verified"`
UpdatedAt time.Time `json:"updatedAt"`
CreatedAt time.Time `json:"createdAt"`
}
type paymentIntentStateStore struct {
mu sync.RWMutex
records map[string]paymentIntentVerification
}
func newPaymentIntentStateStore() *paymentIntentStateStore {
return &paymentIntentStateStore{records: make(map[string]paymentIntentVerification)}
}
func (s *paymentIntentStateStore) upsertCreated(paymentIntentID string) {
if strings.TrimSpace(paymentIntentID) == "" {
return
}
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
record, exists := s.records[paymentIntentID]
if !exists {
record = paymentIntentVerification{
PaymentIntentID: paymentIntentID,
CreatedAt: now,
}
}
record.WebhookState = "awaiting_webhook"
record.Message = "Waiting for Stripe webhook verification."
record.Verified = false
record.UpdatedAt = now
s.records[paymentIntentID] = record
}
func (s *paymentIntentStateStore) applyWebhook(result *stripeWebhookResult) {
if result == nil || strings.TrimSpace(result.PaymentIntentID) == "" {
return
}
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
record, exists := s.records[result.PaymentIntentID]
if !exists {
record = paymentIntentVerification{
PaymentIntentID: result.PaymentIntentID,
CreatedAt: now,
}
}
record.WebhookEventID = result.EventID
record.WebhookEventType = result.EventType
record.Message = result.Message
record.Verified = result.Verified
record.UpdatedAt = now
switch result.OrderState {
case "paid_verified_local_only", "paid_unrecorded":
record.WebhookState = "succeeded"
case "payment_failed_verified_local_only", "payment_failed_unrecorded":
record.WebhookState = "failed"
case "processing_verified_local_only", "processing_unrecorded":
record.WebhookState = "processing"
case "canceled_verified_local_only", "canceled_unrecorded":
record.WebhookState = "canceled"
case "ignored":
record.WebhookState = "ignored"
default:
record.WebhookState = "awaiting_webhook"
}
s.records[result.PaymentIntentID] = record
}
func (s *paymentIntentStateStore) get(paymentIntentID string) (paymentIntentVerification, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
record, exists := s.records[paymentIntentID]
return record, exists
}
var paymentIntentStates = newPaymentIntentStateStore()
+30
View File
@@ -0,0 +1,30 @@
package httpx
import (
"encoding/json"
"net/http"
)
type errorEnvelope struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func WriteError(w http.ResponseWriter, status int, code, message string) {
WriteJSON(w, status, errorEnvelope{
Error: errorBody{
Code: code,
Message: message,
},
})
}
+132
View File
@@ -0,0 +1,132 @@
package httpx
import (
"context"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"royal-pop-backend/internal/config"
"royal-pop-backend/internal/database"
"royal-pop-backend/internal/inventory"
"royal-pop-backend/internal/mailer"
"royal-pop-backend/internal/orders"
)
type RouterConfig struct {
Config *config.Config
DB *database.DB
Orders *orders.Store
Stock *inventory.Store
Mailer *mailer.ResendMailer
}
func NewRouter(cfg RouterConfig) http.Handler {
router := chi.NewRouter()
router.Use(chimiddleware.RealIP)
router.Use(chimiddleware.RequestID)
router.Use(chimiddleware.Recoverer)
router.Use(chimiddleware.Timeout(30 * time.Second))
router.Use(corsMiddleware(cfg.Config.AllowedOrigins))
router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
databaseStatus := "disabled"
if cfg.DB != nil {
healthCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := cfg.DB.Health(healthCtx); err != nil {
WriteJSON(w, http.StatusServiceUnavailable, map[string]any{
"service": cfg.Config.AppName,
"status": "degraded",
"database": "unreachable",
})
return
}
databaseStatus = "ok"
}
WriteJSON(w, http.StatusOK, map[string]any{
"service": cfg.Config.AppName,
"status": "ok",
"database": databaseStatus,
})
})
router.Post("/webhooks/stripe", handleStripeWebhook(cfg.Config, cfg.Orders, cfg.Mailer))
router.Route("/v1", func(api chi.Router) {
api.Get("/inventory", handleListPublicInventory(cfg.Stock))
api.Get("/storefront/pricing", handleGetStorefrontPricing(cfg.Config))
api.Get("/", func(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": cfg.Config.AppName,
"status": "scaffolded",
"version": "v1",
})
})
api.Post("/checkout/payment-intent", handleCreatePaymentIntent(cfg.Config, cfg.Orders))
api.Get("/checkout/payment-intent/{paymentIntentID}", handleGetPaymentIntentStatus(cfg.Config, cfg.Orders))
api.Get("/client/session", handleGetClientSession(cfg.Config))
api.Post("/client/login", handleClientLogin(cfg.Config))
api.Post("/client/logout", handleClientLogout(cfg.Config))
api.Group(func(client chi.Router) {
client.Use(requireClientSession(cfg.Config))
client.Get("/client/orders", handleListClientOrders(cfg.Orders))
client.Get("/client/orders/{orderID}", handleGetClientOrder(cfg.Orders))
client.Patch("/client/orders/{orderID}", handleUpdateClientOrder(cfg.Orders, cfg.Mailer))
client.Get("/client/stock", handleListClientStock(cfg.Stock))
client.Put("/client/stock", handleUpsertClientStock(cfg.Stock))
client.Post("/client/reseed", handleClientReseed(cfg.DB))
})
})
router.NotFound(func(w http.ResponseWriter, _ *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", "The requested endpoint does not exist.")
})
return router
}
func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
normalized := make([]string, 0, len(allowedOrigins))
for _, origin := range allowedOrigins {
trimmed := strings.TrimSpace(origin)
if trimmed == "" {
continue
}
normalized = append(normalized, trimmed)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
for _, allowedOrigin := range normalized {
if allowedOrigin == "*" || strings.EqualFold(allowedOrigin, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
break
}
}
}
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
+236
View File
@@ -0,0 +1,236 @@
package httpx
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/stripe/stripe-go/v83"
stripewebhook "github.com/stripe/stripe-go/v83/webhook"
"royal-pop-backend/internal/config"
"royal-pop-backend/internal/mailer"
"royal-pop-backend/internal/orders"
)
const maxStripeWebhookBodyBytes int64 = 64 * 1024
type stripeWebhookResult struct {
EventID string `json:"eventId"`
EventType string `json:"eventType"`
PaymentIntentID string `json:"paymentIntentId,omitempty"`
OrderState string `json:"orderState"`
Persistence string `json:"persistence"`
Message string `json:"message"`
Verified bool `json:"verified"`
}
func handleStripeWebhook(cfg *config.Config, orderStore *orders.Store, fulfillmentMailer *mailer.ResendMailer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.StripeWebhookSecret == "" {
WriteError(w, http.StatusInternalServerError, "stripe_webhook_not_configured", "Missing STRIPE_WEBHOOK_SECRET for Stripe webhook verification.")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxStripeWebhookBodyBytes)
payload, err := io.ReadAll(r.Body)
if err != nil {
WriteError(w, http.StatusBadRequest, "invalid_webhook_payload", "The Stripe webhook payload could not be read.")
return
}
signature := r.Header.Get("Stripe-Signature")
if strings.TrimSpace(signature) == "" {
WriteError(w, http.StatusBadRequest, "missing_stripe_signature", "Missing Stripe-Signature header.")
return
}
constructOptions := stripewebhook.ConstructEventOptions{}
constructOptions.IgnoreAPIVersionMismatch = true
event, err := stripewebhook.ConstructEventWithOptions(payload, signature, cfg.StripeWebhookSecret, constructOptions)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, stripewebhook.ErrTooOld) {
status = http.StatusUnauthorized
}
log.Printf("stripe webhook rejected: status=%d error=%v", status, err)
WriteError(w, status, "invalid_stripe_signature", err.Error())
return
}
result, err := processStripeWebhookEvent(event)
if err != nil {
WriteError(w, http.StatusBadRequest, "unsupported_webhook_payload", err.Error())
return
}
paymentIntentStates.applyWebhook(result)
if orderStore != nil && result.PaymentIntentID != "" {
updateCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
previousOrder, err := orderStore.GetByPaymentIntentID(updateCtx, result.PaymentIntentID)
if err != nil && !errors.Is(err, orders.ErrOrderNotFound) {
WriteError(w, http.StatusInternalServerError, "order_lookup_failed", "Stripe webhook verification succeeded, but the current order record could not be loaded.")
return
}
if err := orderStore.UpdateFromWebhook(updateCtx, result.PaymentIntentID, orders.WebhookUpdate{
Status: orderStatusForWebhookResult(result.OrderState),
WebhookStatus: webhookStatusForWebhookResult(result.OrderState),
WebhookEventID: result.EventID,
WebhookEventType: result.EventType,
WebhookMessage: result.Message,
}); err != nil && !errors.Is(err, orders.ErrOrderNotFound) {
WriteError(w, http.StatusInternalServerError, "order_webhook_update_failed", "Stripe webhook verification succeeded, but the order record could not be updated.")
return
}
if fulfillmentMailer != nil && fulfillmentMailer.Enabled() {
updatedOrder, err := orderStore.GetByPaymentIntentID(updateCtx, result.PaymentIntentID)
if err != nil && !errors.Is(err, orders.ErrOrderNotFound) {
WriteError(w, http.StatusInternalServerError, "order_reload_failed", "Stripe webhook verification succeeded, but the updated order record could not be loaded.")
return
}
if shouldSendPaidEmail(previousOrder, updatedOrder) {
if err := fulfillmentMailer.SendOrderPaid(updateCtx, updatedOrder); err != nil {
log.Printf("paid email failed for order %s: %v", updatedOrder.ID, err)
}
if err := fulfillmentMailer.SendOrderCreated(updateCtx, updatedOrder); err != nil {
log.Printf("order created reminder email failed for order %s: %v", updatedOrder.ID, err)
}
}
}
}
log.Printf("stripe webhook received: event_id=%s type=%s payment_intent=%s order_state=%s", result.EventID, result.EventType, result.PaymentIntentID, result.OrderState)
WriteJSON(w, http.StatusOK, map[string]any{
"received": true,
"data": result,
})
}
}
func shouldSendPaidEmail(previousOrder, updatedOrder *orders.Order) bool {
if previousOrder == nil || updatedOrder == nil {
return false
}
if updatedOrder.Status != orders.StatusPaid {
return false
}
if previousOrder.Status == orders.StatusPaid {
return false
}
return strings.TrimSpace(updatedOrder.Email) != ""
}
func processStripeWebhookEvent(event stripe.Event) (*stripeWebhookResult, error) {
intent, err := decodePaymentIntentFromEvent(event)
if err != nil {
return nil, err
}
result := &stripeWebhookResult{
EventID: event.ID,
EventType: string(event.Type),
PaymentIntentID: intent.ID,
Persistence: "not_persisted_yet",
}
switch event.Type {
case stripe.EventTypePaymentIntentSucceeded:
result.OrderState = "paid_verified_local_only"
result.Verified = true
result.Message = "Stripe confirmed the payment and updated the order record."
case stripe.EventTypePaymentIntentPaymentFailed:
result.OrderState = "payment_failed_verified_local_only"
result.Verified = true
result.Message = buildPaymentIntentFailureMessage(intent)
case stripe.EventTypePaymentIntentProcessing:
result.OrderState = "processing_verified_local_only"
result.Verified = true
result.Message = "Stripe marked the payment intent as processing. The order record has been updated and is waiting for the next payment event."
case stripe.EventTypePaymentIntentCanceled:
result.OrderState = "canceled_verified_local_only"
result.Verified = true
result.Message = "Stripe marked the payment intent as canceled and the order record has been updated."
default:
result.OrderState = "ignored"
result.Verified = false
result.Message = fmt.Sprintf("Received Stripe event %s. Signature verified, but no persistence action is configured for this event yet.", event.Type)
}
return result, nil
}
func decodePaymentIntentFromEvent(event stripe.Event) (*stripe.PaymentIntent, error) {
if len(event.Data.Raw) == 0 {
return nil, fmt.Errorf("stripe event %s did not include an object payload", event.Type)
}
var intent stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &intent); err != nil {
return nil, fmt.Errorf("stripe event %s could not be parsed as a payment intent: %w", event.Type, err)
}
return &intent, nil
}
func buildPaymentIntentFailureMessage(intent *stripe.PaymentIntent) string {
if intent == nil || intent.LastPaymentError == nil {
return "Stripe reported that the payment intent failed and the order record has been updated."
}
message := strings.TrimSpace(intent.LastPaymentError.Msg)
if message == "" {
return "Stripe reported that the payment intent failed and the order record has been updated."
}
return fmt.Sprintf("Stripe reported that the payment intent failed: %s", message)
}
func orderStatusForWebhookResult(orderState string) string {
switch orderState {
case "paid_verified_local_only", "paid_unrecorded":
return orders.StatusPaid
case "payment_failed_verified_local_only", "payment_failed_unrecorded":
return orders.StatusFailed
case "processing_verified_local_only", "processing_unrecorded":
return orders.StatusProcessing
case "canceled_verified_local_only", "canceled_unrecorded":
return orders.StatusCanceled
default:
return orders.StatusPending
}
}
func webhookStatusForWebhookResult(orderState string) string {
switch orderState {
case "paid_verified_local_only", "paid_unrecorded":
return "succeeded"
case "payment_failed_verified_local_only", "payment_failed_unrecorded":
return "failed"
case "processing_verified_local_only", "processing_unrecorded":
return "processing"
case "canceled_verified_local_only", "canceled_unrecorded":
return "canceled"
case "ignored":
return "ignored"
default:
return "awaiting_webhook"
}
}
+106
View File
@@ -0,0 +1,106 @@
package inventory
import (
"context"
"fmt"
"time"
"royal-pop-backend/internal/database"
)
type Level struct {
Style string
ColorwayID string
FinishID string
QuantityOnHand int64
Notes string
CreatedAt time.Time
UpdatedAt time.Time
}
type UpsertInput struct {
Style string
ColorwayID string
FinishID string
QuantityOnHand int64
Notes string
}
type Store struct {
db *database.DB
}
func NewStore(db *database.DB) *Store {
return &Store{db: db}
}
func (s *Store) List(ctx context.Context) ([]Level, error) {
if s == nil || s.db == nil || s.db.Pool == nil {
return nil, fmt.Errorf("postgres store is not configured")
}
rows, err := s.db.Pool.Query(ctx, `
SELECT style, colorway_id, finish_id, quantity_on_hand, notes, created_at, updated_at
FROM inventory_levels
ORDER BY style ASC, colorway_id ASC, finish_id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
levels := make([]Level, 0)
for rows.Next() {
var level Level
if err := rows.Scan(
&level.Style,
&level.ColorwayID,
&level.FinishID,
&level.QuantityOnHand,
&level.Notes,
&level.CreatedAt,
&level.UpdatedAt,
); err != nil {
return nil, err
}
levels = append(levels, level)
}
if err := rows.Err(); err != nil {
return nil, err
}
return levels, nil
}
func (s *Store) Upsert(ctx context.Context, input UpsertInput) (*Level, error) {
if s == nil || s.db == nil || s.db.Pool == nil {
return nil, fmt.Errorf("postgres store is not configured")
}
row := s.db.Pool.QueryRow(ctx, `
INSERT INTO inventory_levels (style, colorway_id, finish_id, quantity_on_hand, notes)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (style, colorway_id, finish_id)
DO UPDATE SET
quantity_on_hand = EXCLUDED.quantity_on_hand,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING style, colorway_id, finish_id, quantity_on_hand, notes, created_at, updated_at
`, input.Style, input.ColorwayID, input.FinishID, input.QuantityOnHand, input.Notes)
var level Level
if err := row.Scan(
&level.Style,
&level.ColorwayID,
&level.FinishID,
&level.QuantityOnHand,
&level.Notes,
&level.CreatedAt,
&level.UpdatedAt,
); err != nil {
return nil, err
}
return &level, nil
}
File diff suppressed because it is too large Load Diff
+657
View File
@@ -0,0 +1,657 @@
package orders
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"royal-pop-backend/internal/database"
)
var ErrOrderNotFound = errors.New("order not found")
const (
StatusPending = "pending"
StatusPaid = "paid"
StatusFailed = "failed"
StatusProcessing = "processing"
StatusCanceled = "canceled"
FulfillmentPending = "pending"
FulfillmentPaid = "paid"
FulfillmentProcessing = "processing"
FulfillmentPacked = "packed"
FulfillmentShipped = "shipped"
FulfillmentDelivered = "delivered"
FulfillmentCancelled = "cancelled"
)
type CustomerDetails struct {
Email string
Phone string
FirstName string
LastName string
AddressLine1 string
AddressLine2 string
City string
Region string
PostalCode string
Country string
Notes string
}
type LineItem struct {
ItemID string
Style string
ColorwayID string
FinishID string
Quantity int64
UnitAmount int64
}
type CreateOrderInput struct {
OrderID string
Customer CustomerDetails
Items []LineItem
Currency string
Amount int64
StripePriceID string
StripePaymentIntentID string
}
type Order struct {
ID string
Status string
FulfillmentStatus string
Email string
Phone string
FirstName string
LastName string
AddressLine1 string
AddressLine2 string
City string
Region string
PostalCode string
Country string
Notes string
Currency string
Amount int64
StripePriceID string
StripePaymentIntentID string
ShippingCarrier string
TrackingNumber string
FulfillmentNotes string
ShippedAt *time.Time
WebhookStatus string
WebhookEventID string
WebhookEventType string
WebhookMessage string
Items []LineItem
CreatedAt time.Time
UpdatedAt time.Time
}
type WebhookUpdate struct {
Status string
WebhookStatus string
WebhookEventID string
WebhookEventType string
WebhookMessage string
}
type ListFilter struct {
Limit int
Search string
FulfillmentStatus string
}
type UpdateFulfillmentInput struct {
OrderID string
FulfillmentStatus string
ShippingCarrier string
TrackingNumber string
FulfillmentNotes string
ClearShippedAt bool
MarkShippedAtNow bool
}
type Store struct {
db *database.DB
}
func NewStore(db *database.DB) *Store {
return &Store{db: db}
}
func (s *Store) CreatePendingOrder(ctx context.Context, input CreateOrderInput) (*Order, error) {
if s == nil || s.db == nil || s.db.Pool == nil {
return nil, fmt.Errorf("postgres store is not configured")
}
tx, err := s.db.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO orders (
id, status, email, phone, first_name, last_name,
address_line_1, address_line_2, city, region, postal_code, country, notes,
currency, amount, stripe_price_id, stripe_payment_intent_id,
webhook_status, webhook_message
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17,
$18, $19
)
`,
input.OrderID,
StatusPending,
input.Customer.Email,
input.Customer.Phone,
input.Customer.FirstName,
input.Customer.LastName,
input.Customer.AddressLine1,
input.Customer.AddressLine2,
input.Customer.City,
input.Customer.Region,
input.Customer.PostalCode,
input.Customer.Country,
input.Customer.Notes,
input.Currency,
input.Amount,
input.StripePriceID,
input.StripePaymentIntentID,
"awaiting_webhook",
"Waiting for Stripe webhook verification.",
)
if err != nil {
return nil, err
}
for _, item := range input.Items {
quantity := item.Quantity
if quantity <= 0 {
quantity = 1
}
_, err = tx.Exec(ctx, `
INSERT INTO order_items (order_id, item_id, style, colorway_id, finish_id, quantity, unit_amount)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, input.OrderID, item.ItemID, item.Style, item.ColorwayID, item.FinishID, quantity, item.UnitAmount)
if err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetByPaymentIntentID(ctx, input.StripePaymentIntentID)
}
func (s *Store) GetByPaymentIntentID(ctx context.Context, paymentIntentID string) (*Order, error) {
if s == nil || s.db == nil || s.db.Pool == nil {
return nil, fmt.Errorf("postgres store is not configured")
}
row := s.db.Pool.QueryRow(ctx, `
SELECT id, status, fulfillment_status, email, phone, first_name, last_name,
address_line_1, address_line_2, city, region, postal_code, country, notes,
currency, amount, stripe_price_id, stripe_payment_intent_id,
shipping_carrier, tracking_number, fulfillment_notes, shipped_at,
webhook_status, webhook_event_id, webhook_event_type, webhook_message,
created_at, updated_at
FROM orders
WHERE stripe_payment_intent_id = $1
`, paymentIntentID)
var order Order
var shippedAt sql.NullTime
if err := row.Scan(
&order.ID,
&order.Status,
&order.FulfillmentStatus,
&order.Email,
&order.Phone,
&order.FirstName,
&order.LastName,
&order.AddressLine1,
&order.AddressLine2,
&order.City,
&order.Region,
&order.PostalCode,
&order.Country,
&order.Notes,
&order.Currency,
&order.Amount,
&order.StripePriceID,
&order.StripePaymentIntentID,
&order.ShippingCarrier,
&order.TrackingNumber,
&order.FulfillmentNotes,
&shippedAt,
&order.WebhookStatus,
&order.WebhookEventID,
&order.WebhookEventType,
&order.WebhookMessage,
&order.CreatedAt,
&order.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrOrderNotFound
}
return nil, err
}
if shippedAt.Valid {
order.ShippedAt = &shippedAt.Time
}
items, err := s.listItemsByOrderID(ctx, order.ID)
if err != nil {
return nil, err
}
order.Items = items
return &order, nil
}
func (s *Store) ListRecent(ctx context.Context, limit int) ([]Order, error) {
if s == nil || s.db == nil || s.db.Pool == nil {
return nil, fmt.Errorf("postgres store is not configured")
}
if limit <= 0 {
limit = 25
}
if limit > 100 {
limit = 100
}
rows, err := s.db.Pool.Query(ctx, `
SELECT id, status, fulfillment_status, email, phone, first_name, last_name,
address_line_1, address_line_2, city, region, postal_code, country, notes,
currency, amount, stripe_price_id, stripe_payment_intent_id,
shipping_carrier, tracking_number, fulfillment_notes, shipped_at,
webhook_status, webhook_event_id, webhook_event_type, webhook_message,
created_at, updated_at
FROM orders
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
ordersList := make([]Order, 0, limit)
for rows.Next() {
var order Order
var shippedAt sql.NullTime
if err := rows.Scan(
&order.ID,
&order.Status,
&order.FulfillmentStatus,
&order.Email,
&order.Phone,
&order.FirstName,
&order.LastName,
&order.AddressLine1,
&order.AddressLine2,
&order.City,
&order.Region,
&order.PostalCode,
&order.Country,
&order.Notes,
&order.Currency,
&order.Amount,
&order.StripePriceID,
&order.StripePaymentIntentID,
&order.ShippingCarrier,
&order.TrackingNumber,
&order.FulfillmentNotes,
&shippedAt,
&order.WebhookStatus,
&order.WebhookEventID,
&order.WebhookEventType,
&order.WebhookMessage,
&order.CreatedAt,
&order.UpdatedAt,
); err != nil {
return nil, err
}
if shippedAt.Valid {
order.ShippedAt = &shippedAt.Time
}
items, err := s.listItemsByOrderID(ctx, order.ID)
if err != nil {
return nil, err
}
order.Items = items
ordersList = append(ordersList, order)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ordersList, nil
}
func (s *Store) ListForDashboard(ctx context.Context, filter ListFilter) ([]Order, error) {
if s == nil || s.db == nil || s.db.Pool == nil {
return nil, fmt.Errorf("postgres store is not configured")
}
limit := filter.Limit
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
search := strings.TrimSpace(filter.Search)
fulfillmentStatus := strings.TrimSpace(strings.ToLower(filter.FulfillmentStatus))
rows, err := s.db.Pool.Query(ctx, `
SELECT id, status, fulfillment_status, email, phone, first_name, last_name,
address_line_1, address_line_2, city, region, postal_code, country, notes,
currency, amount, stripe_price_id, stripe_payment_intent_id,
shipping_carrier, tracking_number, fulfillment_notes, shipped_at,
webhook_status, webhook_event_id, webhook_event_type, webhook_message,
created_at, updated_at
FROM orders
WHERE ($1 = '' OR fulfillment_status = $1)
AND (
$2 = ''
OR id ILIKE '%' || $2 || '%'
OR first_name ILIKE '%' || $2 || '%'
OR last_name ILIKE '%' || $2 || '%'
OR email ILIKE '%' || $2 || '%'
OR tracking_number ILIKE '%' || $2 || '%'
)
ORDER BY created_at DESC
LIMIT $3
`, fulfillmentStatus, search, limit)
if err != nil {
return nil, err
}
defer rows.Close()
ordersList := make([]Order, 0, limit)
for rows.Next() {
var order Order
var shippedAt sql.NullTime
if err := rows.Scan(
&order.ID,
&order.Status,
&order.FulfillmentStatus,
&order.Email,
&order.Phone,
&order.FirstName,
&order.LastName,
&order.AddressLine1,
&order.AddressLine2,
&order.City,
&order.Region,
&order.PostalCode,
&order.Country,
&order.Notes,
&order.Currency,
&order.Amount,
&order.StripePriceID,
&order.StripePaymentIntentID,
&order.ShippingCarrier,
&order.TrackingNumber,
&order.FulfillmentNotes,
&shippedAt,
&order.WebhookStatus,
&order.WebhookEventID,
&order.WebhookEventType,
&order.WebhookMessage,
&order.CreatedAt,
&order.UpdatedAt,
); err != nil {
return nil, err
}
if shippedAt.Valid {
order.ShippedAt = &shippedAt.Time
}
items, err := s.listItemsByOrderID(ctx, order.ID)
if err != nil {
return nil, err
}
order.Items = items
ordersList = append(ordersList, order)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ordersList, nil
}
func (s *Store) GetByID(ctx context.Context, orderID string) (*Order, error) {
if s == nil || s.db == nil || s.db.Pool == nil {
return nil, fmt.Errorf("postgres store is not configured")
}
row := s.db.Pool.QueryRow(ctx, `
SELECT id, status, fulfillment_status, email, phone, first_name, last_name,
address_line_1, address_line_2, city, region, postal_code, country, notes,
currency, amount, stripe_price_id, stripe_payment_intent_id,
shipping_carrier, tracking_number, fulfillment_notes, shipped_at,
webhook_status, webhook_event_id, webhook_event_type, webhook_message,
created_at, updated_at
FROM orders
WHERE id = $1
`, strings.TrimSpace(orderID))
var order Order
var shippedAt sql.NullTime
if err := row.Scan(
&order.ID,
&order.Status,
&order.FulfillmentStatus,
&order.Email,
&order.Phone,
&order.FirstName,
&order.LastName,
&order.AddressLine1,
&order.AddressLine2,
&order.City,
&order.Region,
&order.PostalCode,
&order.Country,
&order.Notes,
&order.Currency,
&order.Amount,
&order.StripePriceID,
&order.StripePaymentIntentID,
&order.ShippingCarrier,
&order.TrackingNumber,
&order.FulfillmentNotes,
&shippedAt,
&order.WebhookStatus,
&order.WebhookEventID,
&order.WebhookEventType,
&order.WebhookMessage,
&order.CreatedAt,
&order.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrOrderNotFound
}
return nil, err
}
if shippedAt.Valid {
order.ShippedAt = &shippedAt.Time
}
items, err := s.listItemsByOrderID(ctx, order.ID)
if err != nil {
return nil, err
}
order.Items = items
return &order, nil
}
func (s *Store) UpdateFulfillment(ctx context.Context, input UpdateFulfillmentInput) (*Order, error) {
if s == nil || s.db == nil || s.db.Pool == nil {
return nil, fmt.Errorf("postgres store is not configured")
}
status := normalizeFulfillmentStatus(input.FulfillmentStatus)
if status == "" {
return nil, fmt.Errorf("invalid fulfillment status")
}
trimmedOrderID := strings.TrimSpace(input.OrderID)
shippingCarrier := strings.TrimSpace(input.ShippingCarrier)
trackingNumber := strings.TrimSpace(input.TrackingNumber)
fulfillmentNotes := strings.TrimSpace(input.FulfillmentNotes)
var shippedAt any
if input.MarkShippedAtNow {
shippedAt = time.Now().UTC()
}
commandTag, err := s.db.Pool.Exec(ctx, `
UPDATE orders
SET fulfillment_status = $2,
shipping_carrier = $3,
tracking_number = $4,
fulfillment_notes = $5,
shipped_at = CASE
WHEN $6::timestamptz IS NOT NULL THEN $6::timestamptz
WHEN $7 THEN NULL
ELSE shipped_at
END,
updated_at = NOW()
WHERE id = $1
`, trimmedOrderID, status, shippingCarrier, trackingNumber, fulfillmentNotes, shippedAt, input.ClearShippedAt)
if err != nil {
return nil, err
}
if commandTag.RowsAffected() == 0 {
return nil, ErrOrderNotFound
}
return s.GetByID(ctx, trimmedOrderID)
}
func (s *Store) UpdateFromWebhook(ctx context.Context, paymentIntentID string, update WebhookUpdate) error {
if s == nil || s.db == nil || s.db.Pool == nil {
return fmt.Errorf("postgres store is not configured")
}
fulfillmentStatus := ""
switch update.Status {
case StatusPaid:
fulfillmentStatus = FulfillmentPaid
case StatusFailed, StatusCanceled:
fulfillmentStatus = FulfillmentCancelled
}
commandTag, err := s.db.Pool.Exec(ctx, `
UPDATE orders
SET status = $2,
fulfillment_status = CASE
WHEN $7 <> '' AND (fulfillment_status = '' OR fulfillment_status = $8 OR fulfillment_status = $9)
THEN $7
ELSE fulfillment_status
END,
webhook_status = $3,
webhook_event_id = $4,
webhook_event_type = $5,
webhook_message = $6,
updated_at = NOW()
WHERE stripe_payment_intent_id = $1
`, paymentIntentID, update.Status, update.WebhookStatus, update.WebhookEventID, update.WebhookEventType, update.WebhookMessage, fulfillmentStatus, FulfillmentPending, FulfillmentPaid)
if err != nil {
return err
}
if commandTag.RowsAffected() == 0 {
return ErrOrderNotFound
}
return nil
}
func NewOrderID() string {
raw := make([]byte, 8)
_, _ = rand.Read(raw)
return "rpo_" + hex.EncodeToString(raw)
}
func (s *Store) listItemsByOrderID(ctx context.Context, orderID string) ([]LineItem, error) {
rows, err := s.db.Pool.Query(ctx, `
SELECT item_id, style, colorway_id, finish_id, quantity, unit_amount
FROM order_items
WHERE order_id = $1
ORDER BY id ASC
`, orderID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]LineItem, 0)
for rows.Next() {
var item LineItem
if err := rows.Scan(
&item.ItemID,
&item.Style,
&item.ColorwayID,
&item.FinishID,
&item.Quantity,
&item.UnitAmount,
); err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func normalizeFulfillmentStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case FulfillmentPending:
return FulfillmentPending
case FulfillmentPaid:
return FulfillmentPaid
case FulfillmentProcessing:
return FulfillmentProcessing
case FulfillmentPacked:
return FulfillmentPacked
case FulfillmentShipped:
return FulfillmentShipped
case FulfillmentDelivered:
return FulfillmentDelivered
case FulfillmentCancelled, StatusCanceled:
return FulfillmentCancelled
default:
return ""
}
}