Version 1
This commit is contained in:
@@ -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