From b80ce59804e8fdc47fde12a0ff80d81cec59fb87 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 26 Aug 2025 21:52:57 -0700 Subject: wip --- lib/square.ts | 249 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 lib/square.ts diff --git a/lib/square.ts b/lib/square.ts new file mode 100644 index 00000000..e00f0a44 --- /dev/null +++ b/lib/square.ts @@ -0,0 +1,249 @@ +// squareSync.ts — Square v40+, per-user OAuth creds from Redis +import { SquareClient, SquareEnvironment, Square } from "square"; +import config from "$config"; +import { g, s } from "$lib/db"; +import { l, warn } from "$lib/logging"; + +// ----- Types coming from your code ----- +type CoinosInv = { + id: string; + currency: string | Square.Currency; + rate: number; // fiat per BTC captured at invoice time + items?: Array; + memo?: string; +}; + +type CoinosPayment = { + id: string; // Coinos payment UUID + iid: string; // invoice id + amount: number; // sats, excludes tip + tip: number; // sats + rate: number; // fiat per BTC (final) + currency: string | Square.Currency; + type: "bitcoin" | "liquid" | "lightning" | "internal" | string; + ref?: string; // on-chain: "...:txid:vout", LN: preimage etc. + payment_hash?: string; // LN hash if available + memo?: string; +}; + +type CoinosUser = { id: string; username?: string }; + +// ----- Helpers ----- +const toBigCents = (n: number) => BigInt(n); +const satsToCents = (sats: number, fiatPerBTC: number) => + Math.round((sats / 100_000_000) * fiatPerBTC * 100); + +const nowISO = () => new Date().toISOString(); +const isPast = (iso?: string, skewSeconds = 60) => + !!iso && Date.parse(iso) - skewSeconds * 1000 <= Date.now(); + +const normalizeCurrency = (c: string | Square.Currency): Square.Currency => + (typeof c === "string" ? c : String(c)).toUpperCase() as Square.Currency; + +const networkLabel = (t: CoinosPayment["type"]) => + t === "lightning" ? "LIGHTNING" : t === "bitcoin" || t === "liquid" ? "ONCHAIN" : "OTHER"; + +// ----- Per-user Square auth in Redis ----- +// Key: `${uid}:square` -> JSON: { accessToken, tokenType, expiresAt, merchantId, refreshToken, shortLived, refreshTokenExpiresAt } +type RedisSquareAuth = { + accessToken: string; + tokenType: "bearer"; + expiresAt?: string; // ISO + merchantId?: string; + refreshToken?: string; + shortLived?: boolean; + refreshTokenExpiresAt?: string; +}; + +async function loadSquareAuth(uid: string): Promise { + const raw = await g(`${uid}:square`); + if (!raw) return null; + return typeof raw === "string" ? JSON.parse(raw) : (raw as RedisSquareAuth); +} + +// Save back (preserve unknown fields) +async function saveSquareAuth(uid: string, next: Partial) { + const current = (await loadSquareAuth(uid)) || ({} as RedisSquareAuth); + const merged = { ...current, ...next }; + await s(`${uid}:square`, JSON.stringify(merged)); + return merged; +} + +// Create an SDK client for any token (or no token for OAuth) +function makeClient(token?: string) { + return new SquareClient({ + token: token ?? "", // OAuth obtainToken doesn’t require bearer auth + environment: + config.env === "production" + ? SquareEnvironment.Production + : SquareEnvironment.Sandbox, + }); +} + +// Refresh the user’s access token if needed +async function ensureFreshAccessToken(uid: string): Promise { + const auth = await loadSquareAuth(uid); + if (!auth) return null; + + // If no expiry recorded, just use the token as-is + if (!auth.expiresAt || !isPast(auth.expiresAt)) return auth; + + // Expired — try to refresh + if (!auth.refreshToken) { + warn("Square: token expired and no refresh token present", uid); + return auth; + } + if (!config.square?.clientId || !config.square?.clientSecret) { + warn("Square: missing clientId/clientSecret for OAuth refresh"); + return auth; + } + + try { + const oauth = makeClient().oAuth; // new SDK OAuth resource + const res = await oauth.obtainToken({ + clientId: config.square.clientId, + clientSecret: config.square.clientSecret, + grantType: "refresh_token", + refreshToken: auth.refreshToken, + }); + // Update stored creds (Square may rotate fields) + const updated = await saveSquareAuth(uid, { + accessToken: res.accessToken!, + tokenType: (res.tokenType as "bearer") ?? "bearer", + expiresAt: res.expiresAt, + merchantId: res.merchantId ?? auth.merchantId, + refreshToken: res.refreshToken ?? auth.refreshToken, + // shortLived is optional; keep existing if not provided + shortLived: typeof (res as any).shortLived === "boolean" ? (res as any).shortLived : auth.shortLived, + refreshTokenExpiresAt: (res as any).refreshTokenExpiresAt ?? auth.refreshTokenExpiresAt, + }); + l("Square OAuth refresh ok", { uid, oldExp: auth.expiresAt, newExp: updated.expiresAt }); + return updated; + } catch (e: any) { + warn("Square OAuth refresh failed", e?.message || e); + return auth; // fall back to (expired) auth — caller can decide to skip + } +} + +// Pick/cached location for the seller +async function getOrCacheLocationId(uid: string, client: SquareClient): Promise { + const cacheKey = `${uid}:square:locationId`; + const fromCache = await g(cacheKey); + if (typeof fromCache === "string" && fromCache) return fromCache; + if (fromCache && (fromCache as any).id) return (fromCache as any).id; + + // Query seller’s locations and choose the first ACTIVE (or first if none ACTIVE) + const list = await client.locations.list(); + // Auto-pagination can return a pager; handle both shapes + let firstActiveId: string | undefined; + if (Array.isArray((list as any).data)) { + const page = (list as any).data as Square.Location[]; + firstActiveId = page.find((loc) => loc.status === "ACTIVE")?.id || page[0]?.id; + } else { + const resp = list as { locations?: Square.Location[] }; + firstActiveId = resp.locations?.find((loc) => loc.status === "ACTIVE")?.id || resp.locations?.[0]?.id; + } + + if (!firstActiveId) { + warn("Square: no locations found for seller", uid); + return null; + } + + await s(cacheKey, firstActiveId); + return firstActiveId; +} + +// ----- Public entry: syncSquare ----- +export async function syncSquare(inv: CoinosInv, p: CoinosPayment, user: CoinosUser) { + // 1) Load/refresh per-user OAuth creds + const auth = await ensureFreshAccessToken(user.id); + if (!auth?.accessToken) { + warn("Square: no credentials for user; skipping sync", user.id); + return; + } + + // 2) Build a client for this seller and find a location + const client = makeClient(auth.accessToken); + const locationId = await getOrCacheLocationId(user.id, client); + if (!locationId) { + warn("Square: no locationId; skipping sync"); + return; + } + + // 3) Amounts and currency + const itemSats = p.amount || 0; + const tipSats = p.tip || 0; + const itemCents = satsToCents(itemSats, p.rate); + const tipCents = satsToCents(tipSats, p.rate); + const totalCents = itemCents + tipCents; + const cur = normalizeCurrency(p.currency || inv.currency); + + // 4) Create (or reuse) an Order + const orderResp = await client.orders.create({ + idempotencyKey: `coinos-order-${inv.id}`, + order: { + locationId, + metadata: { + coinos_invoice_id: inv.id, + coinos_payment_id: p.id, + coinos_user_id: user.id, + coinos_synced_at: nowISO(), + }, + lineItems: [ + { + name: inv.memo?.slice(0, 255) || "Coinos Order", + quantity: "1", + basePriceMoney: { amount: toBigCents(itemCents), currency: cur }, + }, + ], + ...(tipCents > 0 + ? { + serviceCharges: [ + { + name: "Tip", + calculationPhase: "TOTAL_PHASE", + taxable: false, + amountMoney: { amount: toBigCents(tipCents), currency: cur }, + }, + ], + } + : {}), + }, + }); + const orderId = orderResp.order?.id; + if (!orderId) throw new Error("Square: create order returned no order.id"); + + // 5) Record an EXTERNAL payment (approved, not autocompleted) + const short = (s?: string, n = 18) => (s ? (s.length > n ? s.slice(0, n) + "…" : s) : ""); + const refParts = (p.ref || "").split(":"); + const maybeTxid = refParts.length >= 2 ? refParts[refParts.length - 2] : undefined; + + const note = [ + `Coinos invoice ${inv.id}`, + p.payment_hash ? `hash ${short(p.payment_hash)}` : "", + maybeTxid ? `txid ${short(maybeTxid)}` : "", + inv.memo ? `memo ${short(inv.memo, 60)}` : "", + ].filter(Boolean).join(" | "); + + const payResp = await client.payments.create({ + idempotencyKey: `coinos-pay-${p.id}`, + locationId, + orderId, + sourceId: "EXTERNAL", + amountMoney: { amount: toBigCents(totalCents), currency: cur }, + autocomplete: false, // keep APPROVED; we’ll pay the order next + externalDetails: { type: "OTHER", source: `Coinos ${networkLabel(p.type)}` }, + note, + }); + const paymentId = payResp.payment?.id; + if (!paymentId) throw new Error("Square: create payment returned no payment.id"); + + // 6) Pay the order with our external payment + await client.orders.pay({ + orderId, + idempotencyKey: `coinos-payorder-${orderId}-${paymentId}`, + paymentIds: [paymentId], + }); + + l("Square sync complete", { orderId, paymentId, invoice: inv.id, user: user.id, locationId }); +} -- cgit v1.2.3