package woocommerce import ( "context" "errors" "encoding/json" "fmt" "math/big" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) const ( defaultOrdersSyncLimit = 500 defaultReviewsSyncLimit = 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"` } type ReviewsSyncSummary struct { Pages int `json:"pages"` Fetched int `json:"fetched"` Upserted int `json:"upserted"` Failed int `json:"failed"` } func (s *Service) EnqueueOrdersSync(ctx context.Context, companyID uuid.UUID) (map[string]any, error) { if err := s.requireEnabledCreds(ctx, companyID); err != nil { return nil, err } sc, err := s.loadStored(ctx, companyID) if err != nil { return nil, err } opt := parseSyncOptions(sc.syncOptions) opt.PendingOrdersSync = true if err := s.saveSyncOptions(ctx, companyID, opt); err != nil { return nil, err } return map[string]any{"status": "accepted", "message": "woocommerce orders sync queued"}, nil } func (s *Service) EnqueueReviewsSync(ctx context.Context, companyID uuid.UUID) (map[string]any, error) { if err := s.requireEnabledCreds(ctx, companyID); err != nil { return nil, err } sc, err := s.loadStored(ctx, companyID) if err != nil { return nil, err } opt := parseSyncOptions(sc.syncOptions) opt.PendingReviewsSync = true if err := s.saveSyncOptions(ctx, companyID, opt); err != nil { return nil, err } return map[string]any{"status": "accepted", "message": "woocommerce reviews sync queued"}, nil } func (s *Service) requireEnabledCreds(ctx context.Context, companyID uuid.UUID) error { sc, err := s.loadStored(ctx, companyID) if errors.Is(err, pgx.ErrNoRows) { return ErrNotConfigured } if err != nil { return err } if !sc.enabled { return ErrNotEnabled } key, err := DecryptSecret(s.Key, sc.keyEnc) if err != nil { return err } secret, err := DecryptSecret(s.Key, sc.secretEnc) if err != nil { return err } if sc.storeURL == "" || key == "" || secret == "" { return ErrMissingCreds } return nil } func (s *Service) saveSyncOptions(ctx context.Context, companyID uuid.UUID, opt SyncOptions) error { pruneStringIntMap(opt.ProductIDs, maxProductIDMap) raw, err := json.Marshal(opt) if err != nil { return err } _, err = s.Pool.Exec(ctx, ` UPDATE woocommerce_configs SET sync_options = $2, updated_at = now() WHERE company_id = $1`, companyID, raw) return err } // ClaimNextPendingJob claims the next pending Woo sync (products, orders, or reviews). func (s *Service) ClaimNextPendingJob(ctx context.Context) (uuid.UUID, string, error) { var companyID uuid.UUID var kind string err := s.Pool.QueryRow(ctx, ` WITH candidate AS ( SELECT company_id, CASE WHEN COALESCE(sync_options->>'pending_sync', 'false') = 'true' THEN 'products' WHEN COALESCE(sync_options->>'pending_orders_sync', 'false') = 'true' THEN 'orders' WHEN COALESCE(sync_options->>'pending_reviews_sync', 'false') = 'true' THEN 'reviews' ELSE '' END AS kind FROM woocommerce_configs WHERE is_enabled = true AND ( COALESCE(sync_options->>'pending_sync', 'false') = 'true' OR COALESCE(sync_options->>'pending_orders_sync', 'false') = 'true' OR COALESCE(sync_options->>'pending_reviews_sync', 'false') = 'true' ) ORDER BY updated_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED ) UPDATE woocommerce_configs c SET sync_options = CASE candidate.kind WHEN 'products' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_sync}', 'false'::jsonb, true) WHEN 'orders' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_orders_sync}', 'false'::jsonb, true) WHEN 'reviews' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_reviews_sync}', 'false'::jsonb, true) ELSE c.sync_options END, updated_at = now() FROM candidate WHERE c.company_id = candidate.company_id AND candidate.kind <> '' RETURNING c.company_id, candidate.kind`).Scan(&companyID, &kind) return companyID, kind, err } // SyncOrders pulls Woo 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 } pageSize := defaultPullPageSize summary := OrdersSyncSummary{} after := strings.TrimSpace(opt.OrdersModifiedAfter) for page := 1; summary.Fetched < limit; page++ { remaining := limit - summary.Fetched perPage := pageSize if remaining < perPage { perPage = remaining } orders, _, err := client.ListOrdersPage(ctx, page, perPage, after) if err != nil { opt.PendingOrdersSync = false opt.LastOrdersSyncStatus = "failed" opt.LastOrdersSyncError = truncateErr(err) _ = s.saveSyncOptions(ctx, companyID, opt) return summary, err } if len(orders) == 0 { break } summary.Pages++ summary.Fetched += len(orders) for _, order := range orders { nItems, err := s.upsertOrder(ctx, companyID, order) if err != nil { summary.Failed++ continue } summary.Upserted++ summary.ItemsSaved += nItems } if len(orders) < perPage { break } } opt.PendingOrdersSync = false if summary.Failed > 0 && summary.Upserted == 0 { opt.LastOrdersSyncStatus = "failed" opt.LastOrdersSyncError = "all order upserts failed" } else if summary.Failed > 0 { opt.LastOrdersSyncStatus = "partial" opt.LastOrdersSyncError = "some order upserts failed" } else { opt.LastOrdersSyncStatus = "success" opt.LastOrdersSyncError = "" } now := timeNowUTC() opt.LastOrdersSyncedAt = &now 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) { payload := order.Raw if len(payload) == 0 { b, err := json.Marshal(order) if err != nil { return 0, err } payload = b } email := strings.TrimSpace(strings.ToLower(order.Billing.Email)) name := strings.TrimSpace(strings.TrimSpace(order.Billing.FirstName + " " + order.Billing.LastName)) orderedAt := parseWooTime(order.DateCreatedGMT, order.DateCreated) total := parseDecimal(order.Total) tx, err := s.Pool.Begin(ctx) if err != nil { return 0, err } defer tx.Rollback(ctx) var orderID uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO woo_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, NULLIF($6, 0), NULLIF($7, ''), NULLIF($8, ''), $9, $10::jsonb, 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, order.Status, order.Currency, total, order.CustomerID, email, name, orderedAt, payload, ).Scan(&orderID) if err != nil { return 0, err } _, err = tx.Exec(ctx, `DELETE FROM woo_order_items WHERE company_id = $1 AND order_id = $2`, companyID, orderID) if err != nil { return 0, err } saved := 0 for _, item := range order.LineItems { itemPayload, _ := json.Marshal(item) cats := extractItemCategories(item) catsRaw, _ := json.Marshal(cats) itemTotal := parseDecimal(item.Total) _, err := tx.Exec(ctx, ` INSERT INTO woo_order_items ( company_id, order_id, external_id, product_id, variation_id, sku, name, quantity, total, categories, payload, updated_at ) VALUES ( $1, $2, $3, NULLIF($4, 0), NULLIF($5, 0), $6, $7, $8, $9, $10::jsonb, $11::jsonb, now() ) ON CONFLICT (company_id, order_id, external_id) DO UPDATE SET product_id = EXCLUDED.product_id, variation_id = EXCLUDED.variation_id, sku = EXCLUDED.sku, name = EXCLUDED.name, quantity = EXCLUDED.quantity, total = EXCLUDED.total, categories = EXCLUDED.categories, payload = EXCLUDED.payload, updated_at = now()`, companyID, orderID, item.ID, item.ProductID, item.VariationID, strings.TrimSpace(item.SKU), strings.TrimSpace(item.Name), item.Quantity, itemTotal, catsRaw, itemPayload, ) if err != nil { return 0, err } saved++ } if err := tx.Commit(ctx); err != nil { return 0, err } return saved, nil } func extractItemCategories(item OrderLineItem) []any { out := make([]any, 0) if len(item.MetaData) == 0 { return out } var meta []map[string]any if json.Unmarshal(item.MetaData, &meta) != nil { return out } for _, m := range meta { key := strings.ToLower(fmt.Sprint(m["key"])) if key != "categories" && key != "_categories" && key != "category" && !strings.Contains(key, "categor") { continue } switch v := m["value"].(type) { case string: if strings.TrimSpace(v) != "" { out = append(out, strings.TrimSpace(v)) } case []any: out = append(out, v...) case map[string]any: if name, ok := v["name"].(string); ok && name != "" { out = append(out, name) } if id, ok := v["id"]; ok { out = append(out, id) } } } return out } func parseDecimal(s string) *string { s = strings.TrimSpace(s) if s == "" { return nil } if _, _, err := big.ParseFloat(s, 10, 64, big.ToNearestEven); err != nil { return nil } return &s } func truncateErr(err error) string { if err == nil { return "" } msg := err.Error() if len(msg) > 400 { return msg[:400] + "…" } return msg } func timeNowUTC() time.Time { return time.Now().UTC() }