import { Client, Environment } from 'square' import type { CatalogItem, CatalogVariation, ModifierList, ModifierOption } from '@/data/mock-catalog' /** Payment client — uses SQUARE_ACCESS_TOKEN / SQUARE_ENVIRONMENT (may be sandbox during testing) */ function getClient() { return new Client({ accessToken: process.env.SQUARE_ACCESS_TOKEN!, environment: process.env.SQUARE_ENVIRONMENT === 'production' ? Environment.Production : Environment.Sandbox, }) } /** * Catalog client — always uses production credentials so real items appear * even when SQUARE_ENVIRONMENT is set to sandbox for payment testing. * Falls back to the main client if no separate catalog token is configured. */ function getCatalogClient() { const token = process.env.SQUARE_CATALOG_ACCESS_TOKEN ?? process.env.SQUARE_ACCESS_TOKEN! const env = (process.env.SQUARE_CATALOG_ENVIRONMENT ?? process.env.SQUARE_ENVIRONMENT) === 'production' ? Environment.Production : Environment.Sandbox return new Client({ accessToken: token, environment: env }) } export async function getSquareCatalog(): Promise { const client = getCatalogClient() // Fetch all pages (Square paginates listCatalog) const allObjects: Awaited>['result']['objects'] = [] let cursor: string | undefined = undefined do { const response = await client.catalogApi.listCatalog(cursor, 'ITEM,IMAGE,CATEGORY,MODIFIER_LIST') const page = response.result.objects ?? [] allObjects.push(...page) cursor = response.result.cursor } while (cursor) const objects = allObjects // Build modifier list lookup const modifierMap = new Map() objects .filter((o) => o.type === 'MODIFIER_LIST') .forEach((ml) => { const modifiers: ModifierOption[] = (ml.modifierListData?.modifiers ?? []).map((m) => ({ id: m.id!, name: m.modifierData?.name ?? '', priceDelta: m.modifierData?.priceMoney?.amount ? Number(m.modifierData.priceMoney.amount) : null, })) modifierMap.set(ml.id!, { id: ml.id!, name: ml.modifierListData?.name ?? '', selectionType: (ml.modifierListData?.selectionType ?? 'SINGLE') as 'SINGLE' | 'MULTIPLE', minSelected: 0, maxSelected: null, options: modifiers, }) }) // Build image URL lookup const imageMap = new Map() objects .filter((o) => o.type === 'IMAGE') .forEach((img) => { if (img.imageData?.url) imageMap.set(img.id!, img.imageData.url) }) // Resolve category IDs by name from the catalog const categoryId = (name: string) => objects.find( (o) => o.type === 'CATEGORY' && o.categoryData?.name?.toLowerCase() === name )?.id const onlineCategoryId = categoryId('online') const latexCategoryId = categoryId('latex') // Build a full id → name map for all categories const categoryNameMap = new Map() objects .filter((o) => o.type === 'CATEGORY') .forEach((c) => { if (c.id && c.categoryData?.name) categoryNameMap.set(c.id, c.categoryData.name) }) const items = objects .filter((o) => o.type === 'ITEM') .filter((o) => // If an "online" category exists in Square, only show items tagged with it. // If the category doesn't exist in this account, show all items. !onlineCategoryId || (o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId) ) .map((item) => { const data = item.itemData! const variations: CatalogVariation[] = (data.variations ?? []).map((v) => { const varImageIds: string[] = v.itemVariationData?.imageIds ?? [] const varImageUrls = varImageIds .map((id: string) => imageMap.get(id)) .filter((url: string | undefined): url is string => !!url) return { id: v.id!, name: v.itemVariationData?.name ?? 'Default', priceCents: v.itemVariationData?.priceMoney?.amount ? Number(v.itemVariationData.priceMoney.amount) : 0, imageUrls: varImageUrls, inventory: null, // populated separately by getInventoryCounts() } }) const priceAmount = data.variations?.[0]?.itemVariationData?.priceMoney?.amount const imageUrls = (data.imageIds ?? []) .map((id: string) => imageMap.get(id)) .filter((url: string | undefined): url is string => !!url) const imageUrl = imageUrls[0] ?? null const hasLatexColors = (data.modifierListInfo ?? []).some( (info) => modifierMap.get(info.modifierListId!)?.name.toLowerCase().includes('latex colors') ) const modifiers: ModifierList[] = (data.modifierListInfo ?? []) .filter((info) => info.enabled !== false) .filter((info) => !modifierMap.get(info.modifierListId!)?.name.toLowerCase().includes('latex colors')) .map((info) => { const ml = modifierMap.get(info.modifierListId!) if (!ml) return null return { ...ml, minSelected: info.minSelectedModifiers ?? 0, maxSelected: info.maxSelectedModifiers ?? null, // Strip "None" options from every modifier list (e.g. weight) options: ml.options.filter((o) => !o.name.toLowerCase().startsWith('none')), } }) .filter((ml): ml is ModifierList => ml !== null) const itemCategories: { id?: string }[] = data.categories ?? [] // Derive display categories from all Square categories that aren't 'online' or 'latex' const skipIds = new Set([onlineCategoryId, latexCategoryId].filter(Boolean) as string[]) const displayCatNames = itemCategories .filter((c) => c.id && !skipIds.has(c.id)) .map((c) => categoryNameMap.get(c.id!)) .filter((n): n is string => !!n) const primaryCatName = displayCatNames[0] ?? 'Other' const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') const categorySlug = toSlug(primaryCatName) const categorySlugs = displayCatNames.length ? displayCatNames.map(toSlug) : [categorySlug] const categoryLbls = displayCatNames.length ? displayCatNames : [primaryCatName] return { id: item.id!, name: data.name ?? 'Unnamed item', description: data.description ?? '', category: categorySlug, categoryLabel: primaryCatName, categories: categorySlugs, categoryLabels: categoryLbls, price: priceAmount ? Number(priceAmount) : null, imageUrl, imageUrls, featured: false, tags: [], modifiers, showColors: hasLatexColors, colorMin: 1, colorMax: null, chromeSurchargePerColor: 0, variations, } }) return items } /** * Fetch current inventory counts for a list of variation IDs. * Returns a map of variationId → count (only tracked variations are included). */ export async function getInventoryCounts( variationIds: string[] ): Promise> { if (!variationIds.length) return new Map() const client = getClient() const locationId = process.env.SQUARE_LOCATION_ID! const counts = new Map() // Square allows up to 1000 IDs per call; batch just in case const BATCH = 1000 for (let i = 0; i < variationIds.length; i += BATCH) { const batch = variationIds.slice(i, i + BATCH) const { result } = await client.inventoryApi.batchRetrieveInventoryCounts({ catalogObjectIds: batch, locationIds: [locationId], }) for (const entry of result.counts ?? []) { if ( entry.catalogObjectId && entry.state === 'IN_STOCK' && entry.quantity != null ) { const qty = parseFloat(entry.quantity) counts.set(entry.catalogObjectId, Math.max(0, Math.floor(qty))) } } } return counts } /** Find existing Square customer by email, or create one. Returns the customer ID. */ export async function upsertSquareCustomer(params: { givenName: string familyName: string emailAddress: string phoneNumber: string }): Promise { const client = getClient() // Search by email first const { result: searchResult } = await client.customersApi.searchCustomers({ query: { filter: { emailAddress: { exact: params.emailAddress }, }, }, }) if (searchResult.customers?.length) { return searchResult.customers[0].id! } // Create new customer const { result: createResult } = await client.customersApi.createCustomer({ givenName: params.givenName, familyName: params.familyName, emailAddress: params.emailAddress, phoneNumber: params.phoneNumber, referenceId: 'shop', }) return createResult.customer!.id! } export async function createSquareOrder(params: { lineItems: Array<{ catalogObjectId?: string name: string quantity: string basePriceMoney: { amount: bigint; currency: string } }> note: string customerId?: string idempotencyKey?: string serviceCharge?: { name: string; amountCents: number; taxable: boolean } fulfillment?: ( | { type: 'delivery'; recipientName: string; recipientPhone: string; addressLine1: string; deliverAt?: string; deliveryWindowDuration?: string; note?: string } | { type: 'pickup'; recipientName: string; recipientPhone: string; pickupAt?: string } ) }) { const client = getClient() const fulfillments: object[] = params.fulfillment ? params.fulfillment.type === 'delivery' ? [{ type: 'DELIVERY', state: 'PROPOSED', deliveryDetails: { recipient: { displayName: params.fulfillment.recipientName, phoneNumber: params.fulfillment.recipientPhone, address: { addressLine1: params.fulfillment.addressLine1 }, }, scheduleType: 'SCHEDULED', ...(params.fulfillment.deliverAt ? { deliverAt: params.fulfillment.deliverAt } : {}), ...(params.fulfillment.deliveryWindowDuration ? { deliveryWindowDuration: params.fulfillment.deliveryWindowDuration } : {}), ...(params.fulfillment.note ? { note: params.fulfillment.note } : {}), }, }] : [{ type: 'PICKUP', state: 'PROPOSED', pickupDetails: { recipient: { displayName: params.fulfillment.recipientName, phoneNumber: params.fulfillment.recipientPhone, }, pickupAt: params.fulfillment.pickupAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }, }] : [] const isSandbox = process.env.SQUARE_ENVIRONMENT !== 'production' const serviceCharges = params.serviceCharge ? [{ name: params.serviceCharge.name, amountMoney: { amount: BigInt(params.serviceCharge.amountCents), currency: 'USD' }, calculationPhase: 'SUBTOTAL_PHASE', taxable: params.serviceCharge.taxable, }] : undefined const { result } = await client.ordersApi.createOrder({ idempotencyKey: params.idempotencyKey, order: { locationId: process.env.SQUARE_LOCATION_ID!, lineItems: params.lineItems.map((li) => ({ ...li, // Catalog object IDs only exist in production — strip them in sandbox // so Square treats each line item as a custom (ad-hoc) entry instead. catalogObjectId: isSandbox ? undefined : li.catalogObjectId, })), customerId: params.customerId, serviceCharges: serviceCharges, fulfillments: fulfillments.length ? fulfillments : undefined, metadata: { source: 'online-shop' }, }, }) return result.order } export async function createSquarePayment(params: { sourceId: string orderId: string amountMoney: { amount: bigint; currency: string } note: string idempotencyKey: string autocomplete?: boolean // false = pre-authorize (hold) without capturing }) { const client = getClient() const { result } = await client.paymentsApi.createPayment({ sourceId: params.sourceId, idempotencyKey: params.idempotencyKey, amountMoney: params.amountMoney, orderId: params.orderId, locationId: process.env.SQUARE_LOCATION_ID!, note: params.note, autocomplete: params.autocomplete ?? true, }) return result.payment } /** Capture a pre-authorized payment (created with autocomplete: false). */ export async function completeSquarePayment(paymentId: string) { const client = getClient() const { result } = await client.paymentsApi.completePayment(paymentId, {}) return result.payment } /** Void a pre-authorized payment that was never captured. Customer is not charged. */ export async function cancelSquarePayment(paymentId: string) { const client = getClient() const { result } = await client.paymentsApi.cancelPayment(paymentId) return result.payment } /** Retrieve a Square order with its full tender/payment details. */ export async function retrieveSquareOrder(orderId: string) { const client = getClient() const { result } = await client.ordersApi.retrieveOrder(orderId) return result.order }