Files
route-commerce/src/actions/square-products.ts
T

305 lines
9.5 KiB
TypeScript

"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
import { svcHeaders } from "@/lib/svc-headers";
export type SquareCatalogItem = {
id: string;
name: string;
description: string | null;
price: number;
type: string;
imageUrl?: string;
};
function getSquareClient(accessToken: string) {
const env = process.env.SQUARE_ENVIRONMENT === "production"
? SquareEnvironment.Production
: SquareEnvironment.Sandbox;
const config: BaseClientOptions = {
token: accessToken,
environment: env,
};
return new SquareClient(config);
}
function getSquareBaseUrl(accessToken: string) {
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(accessToken);
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(accessToken);
const body: Record<string, unknown> = {
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<SyncResult> {
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 client = getSquareClient(settings.square_access_token);
const errors: string[] = [];
let synced = 0;
try {
// Fetch wholesale products via RPC (avoids rc_product_id bug in direct query)
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rpcRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!rpcRes.ok) {
const errText = await rpcRes.text();
return { success: false, synced: 0, errors: [`Failed to fetch wholesale products: ${errText}`] };
}
const products: Array<{
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;
}> = await rpcRes.json();
// 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<string, string>();
const existingByName = new Map<string, string>();
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<SyncResult> {
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;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
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 bulk_upsert_products RPC
const upsertRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_products: [
{
name: item.name,
description: item.description ?? "",
price,
type: "Pickup & Shipping",
active: true,
image_url: imageUrl,
},
],
}),
}
);
if (upsertRes.ok) {
synced++;
} else {
const errText = await upsertRes.text();
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 };
}