658 lines
16 KiB
Go
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 ""
|
|
}
|
|
}
|