"use server"; import { getAdminUser } from "@/lib/admin-permissions"; import { getPaymentSettings } from "@/actions/payments"; import { pool } from "@/lib/db"; export type SquareCatalogItem = { id: string; name: string; description: string | null; price: number; type: string; imageUrl?: string; }; function getSquareBaseUrl() { const env = process.env.SQUARE_ENVIRONMENT === "production" ? "https://connect.squareup.com" : "https://connect.squareupsandbox.com"; return env; } async function fetchSquareCatalog(accessToken: string, cursor?: string) { const baseUrl = getSquareBaseUrl(); const response = await fetch( `${baseUrl}/v2/catalog/list`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "Square-Version": "2025-01-16", }, body: JSON.stringify({ types: ["ITEM"], ...(cursor ? { cursor } : {}), }), } ); if (!response.ok) { const err = await response.text(); throw new Error(`Square catalog list failed: ${err}`); } return response.json(); } async function upsertSquareCatalogItem( accessToken: string, item: { name: string; description: string; price: number; imageUrl?: string; }, existingItemId?: string ) { const baseUrl = getSquareBaseUrl(); const body: Record = { idempotency_key: crypto.randomUUID(), item: { name: item.name, description: item.description, variations: [ { name: "Default", pricing_type: "FIXED_PRICING", price_money: { amount: BigInt(Math.round(item.price * 100)), currency: "USD", }, }, ], }, }; if (existingItemId) { body.item = { ...body.item as object, id: existingItemId }; } const response = await fetch(`${baseUrl}/v2/catalog/object`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "Square-Version": "2025-01-16", }, body: JSON.stringify(body), }); if (!response.ok) { const err = await response.text(); throw new Error(`Square upsert failed: ${err}`); } return response.json(); } export type SyncResult = { success: boolean; synced: number; errors: string[]; }; export async function syncProductsToSquare(brandId: string): Promise { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] }; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { return { success: false, synced: 0, errors: ["Not authorized"] }; } const settingsResult = await getPaymentSettings(brandId); if (!settingsResult.success || !settingsResult.settings) { return { success: false, synced: 0, errors: ["Could not load payment settings"] }; } const settings = settingsResult.settings; if (!settings.square_access_token) { return { success: false, synced: 0, errors: ["Square not connected"] }; } const errors: string[] = []; let synced = 0; try { // Fetch wholesale products via SECURITY DEFINER RPC const rpcRes = await pool.query<{ id: string; name: string; description: string | null; availability: string; unit_type: string; price_tiers: Array<{ price: number; min_qty: number; max_qty: number | null }>; hp_sku: string | null; hp_item_id: string | null; default_pickup_location: string | null; }>( "SELECT * FROM get_wholesale_products($1)", [brandId] ); const products = rpcRes.rows; // Filter to available products only const availableProducts = products.filter((p) => p.availability === "available"); if (availableProducts.length === 0) { return { success: true, synced: 0, errors: ["No available products to sync"] }; } // Build map of existing Square catalog items by hp_item_id (primary) and name (fallback) const existingByItemId = new Map(); const existingByName = new Map(); let cursor: string | undefined; do { const catalogData = await fetchSquareCatalog(settings.square_access_token, cursor); for (const obj of catalogData.objects ?? []) { if (obj.type === "ITEM" && obj.item?.name) { existingByName.set(obj.item.name, obj.id); // Store item by its catalog object ID (used for upserts) if (obj.id) existingByItemId.set(obj.id, obj.id); } } cursor = catalogData.cursor; } while (cursor); // Upsert each available wholesale product into Square for (const product of availableProducts) { try { // Primary: match by hp_item_id (Square catalog object ID we stored previously) // Secondary: match by exact name if hp_item_id not set let existingId = product.hp_item_id ? existingByItemId.get(product.hp_item_id) ?? undefined : undefined; if (!existingId && !product.hp_item_id) { // Fall back to name-based match (deduped by name) existingId = existingByName.get(product.name); } // Derive price from first price tier, default to 0 const price = product.price_tiers?.[0]?.price ?? 0; // Build description with unit/pack info const unitInfo = product.unit_type ? ` [Unit: ${product.unit_type}]` : ""; const pickupInfo = product.default_pickup_location ? ` | Pickup: ${product.default_pickup_location}` : ""; const description = `${product.description ?? ""}${unitInfo}${pickupInfo}`.trim(); await upsertSquareCatalogItem(settings.square_access_token, { name: product.name, description, price, }, existingId); synced++; } catch (err) { errors.push(`"${product.name}": ${String(err)}`); } } } catch (err) { errors.push(`Sync error: ${String(err)}`); } return { success: errors.length === 0, synced, errors }; } export async function syncProductsFromSquare(brandId: string): Promise { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] }; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { return { success: false, synced: 0, errors: ["Not authorized"] }; } const settingsResult = await getPaymentSettings(brandId); if (!settingsResult.success || !settingsResult.settings) { return { success: false, synced: 0, errors: ["Could not load payment settings"] }; } const settings = settingsResult.settings; if (!settings.square_access_token) { return { success: false, synced: 0, errors: ["Square not connected"] }; } const errors: string[] = []; let synced = 0; let cursor: string | undefined; do { try { const catalogData = await fetchSquareCatalog(settings.square_access_token, cursor); for (const obj of catalogData.objects ?? []) { if (obj.type !== "ITEM" || !obj.item?.name) continue; const item = obj.item; const variations = item.variations ?? []; const priceMoney = variations[0]?.price_money; const price = priceMoney ? Number(priceMoney.amount) / 100 : 0; const imageUrl = obj.item.image_url ?? null; // Sync to RC via SECURITY DEFINER RPC bulk_upsert_products try { await pool.query( "SELECT bulk_upsert_products($1, $2::jsonb)", [ brandId, JSON.stringify([ { name: item.name, description: item.description ?? "", price, type: "Pickup & Shipping", active: true, image_url: imageUrl, }, ]), ] ); synced++; } catch (e: unknown) { const errText = e instanceof Error ? e.message : String(e); errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`); } } cursor = catalogData.cursor; } catch (err) { errors.push(`Catalog batch error: ${String(err)}`); break; } } while (cursor); return { success: errors.length === 0, synced, errors }; }