Version 1
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
.DS_Store
|
||||
tmp
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user