2
0
Files
Royal-Pop-Accessory/Backend/internal/orders/store.go
T
2026-06-23 18:50:37 +01:00

658 lines
16 KiB
Go

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 ""
}
}