package shopify import ( "context" "encoding/json" "math/big" "strconv" "strings" "time" "github.com/google/uuid" ) const ( defaultOrdersSyncLimit = 500 defaultPullPageSize = 50 ) type OrdersSyncSummary struct { Pages int `json:"pages"` Fetched int `json:"fetched"` Upserted int `json:"upserted"` ItemsSaved int `json:"items_saved"` Failed int `json:"failed"` DryRun bool `json:"dry_run"` } type OrderListFilter struct { Status string Email string Since *time.Time Limit int Offset int } type OrderRow struct { ID uuid.UUID `json:"id"` ExternalID int64 `json:"external_id"` Status string `json:"status"` Currency string `json:"currency"` Total *string `json:"total"` CustomerEmail *string `json:"customer_email"` CustomerName *string `json:"customer_name"` OrderedAt *time.Time `json:"ordered_at"` } // SyncOrders pulls Shopify orders in pages and upserts company-scoped rows. func (s *Service) SyncOrders(ctx context.Context, companyID uuid.UUID) (OrdersSyncSummary, error) { client, _, opt, err := s.clientFor(ctx, companyID) if err != nil { return OrdersSyncSummary{}, err } limit := opt.OrdersSyncLimit if limit <= 0 || limit > maxOrdersSyncLimit { limit = defaultOrdersSyncLimit } summary := OrdersSyncSummary{DryRun: client.DryRun} pageInfo := "" fetched := 0 for fetched < limit { summary.Pages++ pageSize := defaultPullPageSize if remaining := limit - fetched; remaining < pageSize { pageSize = remaining } orders, next, err := client.ListOrdersPage(ctx, pageInfo, pageSize) if err != nil { opt.LastOrdersSyncStatus = "failed" opt.LastOrdersSyncError = err.Error() opt.PendingOrdersSync = false _ = s.saveSyncOptions(ctx, companyID, opt) return summary, err } if len(orders) == 0 { break } for _, order := range orders { fetched++ summary.Fetched++ items, err := s.upsertOrder(ctx, companyID, order) if err != nil { summary.Failed++ continue } summary.Upserted++ summary.ItemsSaved += items } if next == "" || len(orders) < pageSize { break } pageInfo = next } now := time.Now().UTC() opt.LastOrdersSyncedAt = &now opt.PendingOrdersSync = false if summary.Failed == 0 { opt.LastOrdersSyncStatus = "success" opt.LastOrdersSyncError = "" } else if summary.Upserted == 0 { opt.LastOrdersSyncStatus = "failed" opt.LastOrdersSyncError = "all order upserts failed" } else { opt.LastOrdersSyncStatus = "partial" opt.LastOrdersSyncError = "some order upserts failed" } if err := s.saveSyncOptions(ctx, companyID, opt); err != nil { return summary, err } return summary, nil } func (s *Service) upsertOrder(ctx context.Context, companyID uuid.UUID, order Order) (int, error) { email := strings.TrimSpace(order.Email) name := "" var customerID *int64 if order.Customer != nil { if email == "" { email = strings.TrimSpace(order.Customer.Email) } name = strings.TrimSpace(order.Customer.FirstName + " " + order.Customer.LastName) if order.Customer.ID > 0 { id := order.Customer.ID customerID = &id } } status := firstNonEmpty(order.FinancialStatus, order.FulfillmentStatus, "unknown") orderedAt := parseShopifyTime(order.CreatedAt) payload := order.Raw if len(payload) == 0 { payload, _ = json.Marshal(order) } var total any if order.TotalPrice != "" { if r, ok := new(big.Rat).SetString(order.TotalPrice); ok { total = r.FloatString(2) } else { total = order.TotalPrice } } var orderID uuid.UUID err := s.Pool.QueryRow(ctx, ` INSERT INTO shopify_orders ( company_id, external_id, status, currency, total, customer_id, customer_email, customer_name, ordered_at, payload, synced_at, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),NULLIF($8,''),$9,$10,now(),now()) ON CONFLICT (company_id, external_id) DO UPDATE SET status = EXCLUDED.status, currency = EXCLUDED.currency, total = EXCLUDED.total, customer_id = EXCLUDED.customer_id, customer_email = EXCLUDED.customer_email, customer_name = EXCLUDED.customer_name, ordered_at = EXCLUDED.ordered_at, payload = EXCLUDED.payload, synced_at = now(), updated_at = now() RETURNING id`, companyID, order.ID, status, order.Currency, total, customerID, email, name, orderedAt, payload, ).Scan(&orderID) if err != nil { return 0, err } itemsSaved := 0 for _, li := range order.LineItems { var lineTotal any if li.Price != "" { if r, ok := new(big.Rat).SetString(li.Price); ok { qty := li.Quantity if qty <= 0 { qty = 1 } r.Mul(r, big.NewRat(int64(qty), 1)) lineTotal = r.FloatString(2) } else { lineTotal = li.Price } } itemPayload, _ := json.Marshal(li) var productID, variantID *int64 if li.ProductID > 0 { id := li.ProductID productID = &id } if li.VariantID > 0 { id := li.VariantID variantID = &id } title := firstNonEmpty(li.Name, li.Title) _, err := s.Pool.Exec(ctx, ` INSERT INTO shopify_order_items ( company_id, order_id, external_id, product_id, variant_id, sku, name, quantity, total, payload, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,now()) ON CONFLICT (company_id, order_id, external_id) DO UPDATE SET product_id = EXCLUDED.product_id, variant_id = EXCLUDED.variant_id, sku = EXCLUDED.sku, name = EXCLUDED.name, quantity = EXCLUDED.quantity, total = EXCLUDED.total, payload = EXCLUDED.payload, updated_at = now()`, companyID, orderID, li.ID, productID, variantID, li.SKU, title, maxInt(li.Quantity, 1), lineTotal, itemPayload, ) if err != nil { continue } itemsSaved++ } return itemsSaved, nil } func maxInt(a, b int) int { if a > b { return a } return b } func parseShopifyTime(v string) *time.Time { if v == "" { return nil } layouts := []string{time.RFC3339, "2006-01-02T15:04:05Z", "2006-01-02T15:04:05-07:00"} for _, layout := range layouts { if t, err := time.Parse(layout, v); err == nil { u := t.UTC() return &u } } return nil } func normalizeOrderListFilter(f OrderListFilter) OrderListFilter { if f.Limit <= 0 || f.Limit > 200 { f.Limit = 50 } if f.Offset < 0 { f.Offset = 0 } return f } func (s *Service) ListOrders(ctx context.Context, companyID uuid.UUID, f OrderListFilter) ([]OrderRow, int, error) { f = normalizeOrderListFilter(f) where := []string{"company_id = $1"} args := []any{companyID} n := 2 if f.Status != "" { where = append(where, "status = $"+itoa(n)) args = append(args, f.Status) n++ } if f.Email != "" { where = append(where, "lower(customer_email) = lower($"+itoa(n)+")") args = append(args, f.Email) n++ } if f.Since != nil { where = append(where, "ordered_at >= $"+itoa(n)) args = append(args, *f.Since) n++ } clause := strings.Join(where, " AND ") var total int if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM shopify_orders WHERE `+clause, args...).Scan(&total); err != nil { return nil, 0, err } args = append(args, f.Limit, f.Offset) rows, err := s.Pool.Query(ctx, ` SELECT id, external_id, status, currency, total::text, customer_email, customer_name, ordered_at FROM shopify_orders WHERE `+clause+` ORDER BY ordered_at DESC NULLS LAST, external_id DESC LIMIT $`+itoa(n)+` OFFSET $`+itoa(n+1), args...) if err != nil { return nil, 0, err } defer rows.Close() out := make([]OrderRow, 0) for rows.Next() { var row OrderRow if err := rows.Scan(&row.ID, &row.ExternalID, &row.Status, &row.Currency, &row.Total, &row.CustomerEmail, &row.CustomerName, &row.OrderedAt); err != nil { return nil, 0, err } out = append(out, row) } return out, total, rows.Err() } func itoa(n int) string { return strconv.Itoa(n) }