Square payments — take real money into the owner's own Square account
prompts/pkg/llms/square.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When someone asks to take money with Square — "I sell on Square", "my
Square account", "pay with Square", "Square checkout" — build it on a
Square payment link from backend.js. The owner pastes their own Square
access token into the vibe's Secrets once, and backend.js calls Square with it.
This page is for owners who already sell on Square: their sales land beside
their counter sales in the Square Dashboard they already use. Every other ask
to take money, with no Square in the words, is the stripe skill.
This is a backend.js feature, and the token is the owner's own key (the
owner-key rules that load with this page). This page is the recipe that keeps
every Square call on the server, keeps "paid" a fact only Square can
establish, and keeps every order honest about where it is.
The price list is the owner's, and it lives in the app.
Each thing for sale is a product record in the app's own database that only the owner writes, from the app's own Products screen, priced in its currency's smallest unit (cents for USD, yen for JPY), and backend.js prices every line from the owner's product records and never from an amount the browser sends.
The owner lists what they sell, sets each price, changes it next week and takes
a thing off sale, all from the app's Products screen with no new build.
access.js takes a product record from the owner alone and checks its price
is a whole number of the currency's smallest unit. The live catalog starts
empty: the owner's first view is the form for their first product, and a buyer
sees Nothing for sale yet until there is one. Square's own item catalog is
left as it is: the order sent to Square carries each line's name, quantity and
price, so the owner keeps one list, here.
Anyone pays, signed in or not. Square's page collects the card, asks the buyer for their email and sends the receipt, so a visitor with no Vibes DIY account fills the bag and pays. The order records the buyer's handle when they happen to be signed in, and a guest's order is just as real. Sign-in is asked for only when the owner's own request asks for it (members only, a "my orders" page).
One-time payments. A payment link takes one payment for one order. Sell a period as a one-time item (a season pass, this month's class) and say so; a renewing Square subscription needs plans in the owner's Square catalog, which is beyond this recipe.
The first sale is real money. The owner's production token charges real
cards, so the Setup screen says so plainly and suggests the owner buy their
cheapest item once and refund it from the Square Dashboard. Square's developer
sandbox is the rehearsal space: an SQUARE_ENV secret of sandbox with the
sandbox token sends every call to Square's sandbox, where the payment page
carries a Test Payment button that pays like a buyer would.
1. The setup item: the owner's Square access token
The Square item is the access token: the backend answers missing-key while ctx.secrets.SQUARE_ACCESS_TOKEN is absent, and its steps link the Square Developer Console by URL and name SQUARE_ACCESS_TOKEN in so many words.
The Setup surface the item lands on (the owner-only entry in the app's
navigation, the verdict from /setup, the re-read on focus that unlocks Pay in
place) is the use-vibe docs' Some apps need setup before they work; this
recipe contributes one item to it. The steps are written for someone who sells
on Square and has never opened its developer site, so each one links the exact
page and uses that page's own words:
- Open the Square Developer Console, https://developer.squareup.com/apps, and sign in with the Square account you sell with.
- Press + (Create an application), give it any name, such as this app's, and save.
- Open the application, choose Credentials, switch the toggle at the top to Production, and copy the Production Access token (press Show, then copy).
- Save it under Secrets on this vibe's Settings tab as
SQUARE_ACCESS_TOKEN, then come back: the owner-key steps every item ends with.
The item is done when the token is saved and Square answered with a
location that takes cards. /setup asks Square on the owner's call alone
(GET /v2/merchants/me and GET /v2/locations) and keeps the answer in a
setup record the backend writes: the business name, the location's name and
its currency. A visitor's read takes the kept record, so visitor traffic spends
nothing of the owner's Square account, and the owner sees Connected to
2. Square stays on the server
Every Square request is made in backend.js with ctx.fetch and ctx.secrets.SQUARE_ACCESS_TOKEN; App.jsx never calls connect.squareup.com.
Square's API sends no CORS headers, so the browser could not reach it anyway,
and the token rides only the backend's request: Authorization: Bearer <token>, Square-Version: 2025-10-16, and a JSON body. The host is
https://connect.squareup.com, or https://connect.squareupsandbox.com when
ctx.secrets.SQUARE_ENV is sandbox.
backend.js calls only the four Square endpoints the platform admits: GET /v2/merchants/me, GET /v2/locations, POST /v2/online-checkout/payment-links and GET /v2/orders/{order_id}.
Those four are a whole shop: who the business is, where it sells, a hosted checkout for one order, and that order read back. Square's own page takes the card, so the app never creates a payment, and refunds stay in the Square Dashboard, where the owner already makes them.
A payment link names one location, and the order is charged in that
location's currency. Pick the first location whose status is ACTIVE and
whose capabilities include CREDIT_CARD_PROCESSING. Products are priced in
that currency's smallest unit (cents for USD, whole yen for JPY: read the digits from
Intl.NumberFormat, never a fixed 100), and the page shows prices in the
currency /setup hands it.
Checkout opens in a new tab with window.open, because the app runs inside a
frame that cannot navigate the page around it. Open the tab inside the click,
before any await, and say so on the button: Pay $24 — Square opens in a new
tab.
3. One order, then one payment link
The order exists before the money moves. /checkout takes the bag, which
products and how many of each, prices every line from the owner's records,
writes an order in pending, and only then asks Square for a payment link
for exactly that order:
jsconst r = await square(ctx, "POST", "/v2/online-checkout/payment-links", {
idempotency_key: order._id, // a retried request makes one link, not two
order: {
location_id: location.id,
reference_id: order._id, // Square keeps our order id, so the read-back proves whose order it is
line_items: order.lines.map((l) => ({
name: l.title,
quantity: String(l.qty), // Square takes the quantity as a string
base_price_money: { amount: l.unitAmount, currency: order.currency },
})),
},
checkout_options: { redirect_url: appUrl(ctx, `#paid=${order._id}`) },
});
// r.body.payment_link: { id, url, order_id }. Keep url and order_id on our order.
- Our order id is
"order-"and a fresh UUID with its dashes removed, 38 characters, because Square capsreference_idat 40. - Keep
payment_link.order_id(Square's order) andpayment_link.urlon our order, with the Square env (productionorsandbox) and the location id it was made in. A second press of Pay on the same bag at the same prices, while that order is pending in the same env and location, reuses its link rather than making another order. Look for it on every page of the pending query, which filters after its limit. - Amounts are integers in the currency's smallest unit: $12.50 is
1250, never12.5, and ¥1,000 is1000, in the product record, the order and Square'samount. The product keeps its currency too, and/checkoutsells only products priced in the location's. - A bag is capped at a sensible number of lines and a quantity per line.
The routes read the orders database, which access.js binds, so backend.js
declares config.fetch.unfilteredReads for it, and owes what that waives:
/confirm answers a signed-in buyer about their own order only, a guest only
the status of the order id their return page names, and no route lists orders
for anyone. The owner's view of every order is the page's own live query.
4. "Paid" is a fact the backend reads back from Square
An order is marked paid only after backend.js reads it back with GET /v2/orders/{order_id} and finds our reference_id, our total, a tender and nothing left to pay.
jsconst so = (await square(ctx, "GET", `/v2/orders/${encodeURIComponent(order.squareOrderId)}`)).body?.order;
const ours = so?.reference_id === order._id && so?.total_money?.amount === order.amount && so?.total_money?.currency === order.currency;
const paid = ours && (so.tenders ?? []).length > 0 && so.net_amount_due_money?.amount === 0;
Square adds nothing to the return address, so the page names its own order
(#paid=<orderId>) and asks /confirm, which reads Square's copy and moves the
order to paid. The address only says which order to ask about: someone who
types #paid= into their own address bar fools only themselves.
A scheduled sweep every 5 minutes settles the orders whose buyer closed Square's tab.
A payment link never expires, so the sweep reads every pending order, however
old: an order leaves pending only when Square's copy says paid, or CANCELED.
Settling is idempotent: an order that is no longer pending comes back as it
is. When an order settles as paid, backend.js tells the owner with ctx.notify
(toHandle the owner, a body naming what sold and for how much, and a
dedupeKey from the order id), so each sale reaches the owner once however
many times it settles. A notice that cannot be delivered is logged and never
undoes the sale. access.js makes the rest structural: only backend.js writes
an order, and an order keeps its buyer and its price.
5. What the buyer and the owner each see
- The buyer presses Pay, Square's page opens in a new tab, and when they
come back the page reads Paid. Thank you! once
/confirmanswers, or Your payment is still confirming while it asks/confirmagain on a short backoff and whenever the page comes back into view, until it reads paid. Square emails their receipt. - The owner's Orders lists every order: who, what, how much, its status, and for a paid one the line Also in your Square Dashboard, where refunds, payouts and reports live.
- The owner's Products is the price list, and Setup shows which Square business and location the app sells through.
6. Errors keep their reason
Square answers a refusal with { errors: [{ category, code, detail }] }. Keep
detail and return { ok: false, reason, message } so the page can say why:
missing-key: the token is not saved yet. A visitor reads Checkout is not open yet; the owner sees the Setup item.401(UNAUTHORIZED): Square refused the token. The item reads To do with Square's sentence, and the fix is a fresh production token saved under the same name.no-location: the account has no active location that takes cards; the Setup item says so in those words.- anything else: Square's own
detail.
Wrap every route in try/catch and answer with a reason, because an uncaught
throw reaches the page as a bare 500 that names nothing the owner can fix.
Complete backend.js
The worked example is a bakery's preorder page: the owner lists the week's boxes, anyone pays on Square's page, and every sale lands in the bakery's Square Dashboard.
backend.js
js// Square payment links for one merchant: the owner's own Square access token, Square's hosted
// checkout page, and orders settled by reading each one back from Square.
export const config = {
scheduled: { interval: "5m" },
// The routes read the owner's catalog and a buyer's own order; access.js binds the database, so
// the fetch lane needs this declaration, and the routes owe the buyerHandle check it waives.
fetch: { unfilteredReads: { dbs: ["orders"], why: "/checkout reads the products the buyer names from the owner's catalog; /confirm reads the buyer's own order, a signed-in buyer's checked by buyerHandle and a guest's answering only its status to the order id their return page names; /setup reads the kept Square setup record" } },
};
const DB = "orders";
const SETUP_ID = "setup:square";
const SQUARE_VERSION = "2025-10-16";
const LINK_TTL_MS = 24 * 60 * 60 * 1000; // a pending order's link is reused for a day
function json(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function appUrl(ctx, hash) {
return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
}
// Production unless the owner set SQUARE_ENV to "sandbox" with a sandbox token. Each order keeps the
// env it was made in, since a sandbox order lives on the sandbox host alone.
function squareEnv(ctx) {
return ctx.secrets.SQUARE_ENV === "sandbox" ? "sandbox" : "production";
}
function squareHost(ctx) {
return squareEnv(ctx) === "sandbox" ? "https://connect.squareupsandbox.com" : "https://connect.squareup.com";
}
// Amounts are in the currency's smallest unit, the one Square charges in: cents for USD, whole yen
// for JPY. Read and show them through that unit, never a fixed 100.
const digits = (currency) => new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions().maximumFractionDigits;
// One Square call on the owner's account. The token rides only this request; Square's own
// sentence is kept when it refuses.
async function square(ctx, method, path, body) {
const token = ctx.secrets.SQUARE_ACCESS_TOKEN;
if (!token) return { ok: false, reason: "missing-key" };
const res = await ctx.fetch(`${squareHost(ctx)}${path}`, {
method,
headers: { authorization: `Bearer ${token}`, "square-version": SQUARE_VERSION, "content-type": "application/json" },
...(body ? { body: JSON.stringify(body) } : {}),
});
const out = await res.json().catch(() => null);
if (!res.ok) return { ok: false, reason: res.status === 401 ? "unauthorized" : "square", status: res.status, message: out?.errors?.[0]?.detail ?? `HTTP ${res.status}` };
return { ok: true, body: out };
}
// The location a payment link names: the first active one that takes cards. Its currency prices the order.
async function mainLocation(ctx) {
const r = await square(ctx, "GET", "/v2/locations");
if (!r.ok) return r;
const loc = (r.body.locations ?? []).find((l) => l.status === "ACTIVE" && (l.capabilities ?? []).includes("CREDIT_CARD_PROCESSING"));
if (!loc) return { ok: false, reason: "no-location", message: "This Square account has no active location that takes cards." };
return { ok: true, location: { id: loc.id, name: loc.name, currency: loc.currency } };
}
// What is for sale is the owner's catalog: `product` records written by the owner alone (access.js).
// The browser names a product and a quantity, never an amount.
const MAX_LINES = 20;
const MAX_QTY = 99;
async function priceBag(ctx, bag, currency) {
if (!Array.isArray(bag) || bag.length === 0 || bag.length > MAX_LINES) return null;
if (!bag.every((item) => Number.isInteger(item?.qty) && item.qty >= 1 && item.qty <= MAX_QTY)) return null;
if (new Set(bag.map((item) => item.productId)).size !== bag.length) return null;
const products = await Promise.all(
bag.map(async (item) => {
if (typeof item.productId !== "string" || !item.productId.startsWith("product-")) return null;
const p = await ctx.db.get(item.productId, { db: DB });
// A product priced in another currency (the owner's location changed since) is not for sale here.
return p?.type === "product" && p.onSale === true && p.currency === currency && Number.isInteger(p.amount) && p.amount > 0 ? p : null;
})
);
if (products.some((p) => !p)) return null;
const lines = products.map((p, i) => ({ productId: p._id, title: p.title, unitAmount: p.amount, qty: bag[i].qty }));
const key = lines.map((l) => `${l.productId}:${l.qty}:${l.unitAmount}`).sort().join(",");
return { lines, amount: lines.reduce((n, l) => n + l.unitAmount * l.qty, 0), key };
}
// The owner hears about each sale once: the dedupeKey is the order.
async function tellOwner(ctx, order) {
const price = new Intl.NumberFormat("en-US", { style: "currency", currency: order.currency }).format(order.amount / 10 ** digits(order.currency));
const buyer = order.buyerHandle ? `@${order.buyerHandle}` : "a guest";
try {
await ctx.notify({ toHandle: ctx.appInfo.ownerHandle, body: `Square sale: ${order.title} to ${buyer}, ${price}`.slice(0, 500), dedupeKey: `sale-${order._id}` });
} catch (err) {
ctx.log("warn", "sale notice not delivered", { id: order._id, reason: err.message }); // the sale stands either way
}
}
// Move one pending order to what Square says about it. Paid = Square's own copy of the order,
// carrying our order id and our total, with a tender and nothing left to pay. A payment link never
// expires, so an order stays pending (and swept) until Square has it paid or canceled.
async function settle(ctx, order) {
if (order.status !== "pending" || !order.squareOrderId) return order;
// The token on hand reads its own env only; an order from the other env settles once the owner switches back.
if ((order.env ?? "production") !== squareEnv(ctx)) return order;
const r = await square(ctx, "GET", `/v2/orders/${encodeURIComponent(order.squareOrderId)}`);
if (!r.ok) return order; // a refusal settles nothing; the next read tries again
const so = r.body?.order;
const ours = so?.reference_id === order._id && so?.total_money?.amount === order.amount && so?.total_money?.currency === order.currency;
const paid = ours && (so.tenders ?? []).length > 0 && so.net_amount_due_money?.amount === 0;
if (!paid) return ours && so.state === "CANCELED" ? (await move(ctx, order, { status: "canceled", canceledAt: Date.now() })).order : order;
const { order: next, moved } = await move(ctx, order, { status: "paid", paidAt: Date.now() });
if (moved) await tellOwner(ctx, next);
return next;
}
// One pending order to its settled state, on the revision we read. When another call settled it
// first, its record stands and this call moved nothing.
async function move(ctx, order, change) {
const next = { ...order, ...change };
try {
await ctx.db.put(next, { db: DB, id: order._id, ifRev: order._rev });
return { order: next, moved: true };
} catch (err) {
if (err.code !== "conflict" && err.code !== "access-denied") throw err;
const stored = await ctx.db.get(order._id, { db: DB });
if (stored && stored.status !== "pending") return { order: stored, moved: false };
throw err;
}
}
export async function fetch(request, ctx) {
const url = new URL(request.url);
// An uncaught throw reaches the app as a bare 500 naming only the error class, so every route answers with a reason.
try {
// The setup verdict: ready and the currency for everyone, the item (and the handle + slug the
// Setup screen links with) for the owner alone. Square is asked on the owner's call only; a
// visitor's read takes the kept record.
if (url.pathname === "/setup" && request.method === "GET") {
const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
const hasKey = Boolean(ctx.secrets.SQUARE_ACCESS_TOKEN);
let kept = await ctx.db.get(SETUP_ID, { db: DB });
let problem = hasKey ? null : { reason: "missing-key" };
if (owner && hasKey) {
const [merchant, loc] = await Promise.all([square(ctx, "GET", "/v2/merchants/me"), mainLocation(ctx)]);
if (merchant.ok && loc.ok) {
kept = { _id: SETUP_ID, type: "setup", business: merchant.body.merchant?.business_name ?? null, location: loc.location, checkedAt: Date.now() };
await ctx.db.put(kept, { db: DB, id: SETUP_ID });
} else {
problem = merchant.ok ? loc : merchant;
}
}
const done = hasKey && !problem && Boolean(kept?.location);
const items = [{ id: "square-token", label: "Your Square access token", done, problem: problem?.message ?? null }];
const ready = items.every((item) => item.done);
const currency = kept?.location?.currency ?? "USD";
if (!owner) return json({ ok: true, ready, owner, currency });
return json({ ok: true, ready, owner, currency, items, square: done ? { business: kept.business, location: kept.location.name, sandbox: ctx.secrets.SQUARE_ENV === "sandbox" } : null, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
}
// Start a purchase: the order first, priced from the owner's catalog, then a payment link for
// exactly that order. Anyone pays: a signed-in buyer's order carries their handle, a guest's
// the key their page keeps. (Members only adds: if (!me) return json({ ok: false, reason: "sign-in" }, 401);)
if (url.pathname === "/checkout" && request.method === "POST") {
const me = ctx.userInfo?.userHandle ?? null;
const { lines: bag, guestKey } = await request.json();
if (!me && !(typeof guestKey === "string" && /^guest-[\w-]{8,64}$/.test(guestKey))) return json({ ok: false, reason: "bad-request" }, 400);
const loc = await mainLocation(ctx);
if (!loc.ok) return json(loc, 503);
const priced = await priceBag(ctx, bag, loc.location.currency);
if (!priced) return json({ ok: false, reason: "unknown-item" }, 400);
const env = squareEnv(ctx);
// One attempt per buyer per bag at these prices, in this env and location: a second press reuses
// the link already made. Every page: the query filters after its limit, so a page can come back
// short or empty with more behind it.
const same = (o) =>
(me ? o.buyerHandle === me : o.guestKey === guestKey) &&
o.bagKey === priced.key &&
o.currency === loc.location.currency &&
o.env === env &&
o.locationId === loc.location.id &&
o.linkUrl &&
Date.now() - o.createdAt < LINK_TTL_MS;
let attempt;
let after;
do {
const page = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100, after });
attempt = page.find(same);
after = page.next;
} while (!attempt && after);
if (attempt) return json({ ok: true, orderId: attempt._id, url: attempt.linkUrl });
const id = "order-" + crypto.randomUUID().replaceAll("-", ""); // Square caps reference_id at 40 characters; this is 38
const order = {
_id: id,
type: "order",
lines: priced.lines,
bagKey: priced.key,
title: priced.lines.length === 1 ? priced.lines[0].title : `${priced.lines[0].title} and ${priced.lines.length - 1} more`,
amount: priced.amount,
currency: loc.location.currency,
env,
locationId: loc.location.id,
buyerHandle: me, // null for a guest; Square's page collects their email and sends the receipt
...(me ? {} : { guestKey }),
status: "pending",
createdAt: Date.now(),
};
await ctx.db.put(order, { db: DB, id });
const r = await square(ctx, "POST", "/v2/online-checkout/payment-links", {
idempotency_key: id,
order: {
location_id: loc.location.id,
reference_id: id,
line_items: order.lines.map((l) => ({ name: l.title, quantity: String(l.qty), base_price_money: { amount: l.unitAmount, currency: order.currency } })),
},
checkout_options: { redirect_url: appUrl(ctx, `#paid=${id}`) },
});
if (!r.ok) {
await ctx.db.put({ ...order, status: "failed", problem: r.message }, { db: DB, id });
return json(r, 502);
}
const link = r.body.payment_link;
await ctx.db.put({ ...order, squareOrderId: link.order_id, linkUrl: link.url }, { db: DB, id });
return json({ ok: true, orderId: id, url: link.url });
}
// The buyer is back: read the order from Square. A signed-in buyer's order answers to them alone;
// a guest's answers its status to the order id their return page holds.
if (url.pathname === "/confirm" && request.method === "POST") {
const me = ctx.userInfo?.userHandle ?? null;
const { orderId } = await request.json();
const order = typeof orderId === "string" && orderId.startsWith("order-") ? await ctx.db.get(orderId, { db: DB }) : null;
if (order?.type !== "order" || (order.buyerHandle && order.buyerHandle !== me)) return json({ ok: false, reason: "not-found" }, 404);
const settled = await settle(ctx, order);
return json({ ok: true, status: settled.status });
}
return json({ ok: false, reason: "not-found" }, 404);
} catch (err) {
ctx.log("error", "square route failed", { path: url.pathname, message: String(err?.message ?? err) });
return json({ ok: false, reason: "handler-failed", message: "Something went wrong reaching Square." }, 500);
}
}
// The sweep: a buyer who paid and closed Square's tab is still marked paid within minutes.
export async function scheduled(event, ctx) {
let after;
let paid = 0;
do {
const page = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100, after });
for (const order of page) {
const next = await settle(ctx, order);
if (next.status === "paid") paid += 1;
}
after = page.next;
} while (after);
ctx.log("info", "square sweep", { paid, at: event.scheduledTime });
}
access.js — only the backend writes an order
access.js
jsexport function orders(doc, oldDoc, user, ctx) {
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
// The catalog: the owner lists each product and sets its price from the app's own screen, and
// anyone reads it. backend.js charges the price on this record, so the owner alone writes one.
if (doc.type === "product") {
ctx.requireRole("owner");
const named = doc._id?.startsWith("product-") && typeof doc.title === "string" && doc.title.trim() !== "";
const priced = typeof doc.currency === "string" && Number.isInteger(doc.amount) && doc.amount > 0;
if (!named || !priced || typeof doc.onSale !== "boolean") {
throw { forbidden: "a product has a name, a currency and a whole price in its smallest unit" };
}
return { channels: ["catalog"], grant: { public: ["catalog"] } };
}
// The kept Square answer (business, location, currency): written by backend.js on the owner's /setup call.
if (doc.type === "setup") {
ctx.requireBackend("fetch");
return { channels: ["orders"], grant: { roles: { owner: ["orders"] } } };
}
if (doc.type !== "order") throw { forbidden: "unknown document type" };
// Orders come only from backend.js, guests included, and an order keeps its buyer and its price.
ctx.requireBackend();
if (oldDoc && (doc.buyerHandle !== oldDoc.buyerHandle || doc.amount !== oldDoc.amount || doc.currency !== oldDoc.currency)) {
throw { forbidden: "an order keeps its buyer and its price" };
}
// allowAnonymous: backend.js writes a guest's order while serving a buyer who has no account.
if (!doc.buyerHandle) return { channels: ["orders"], grant: { roles: { owner: ["orders"] } }, allowAnonymous: true };
const mine = `user:${doc.buyerHandle}`;
return { channels: [mine, "orders"], grant: { users: { [doc.buyerHandle]: [mine] }, roles: { owner: ["orders"] } }, allowAnonymous: true };
}
App.jsx sketch
App.jsx
jsximport React, { useEffect, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { apiFetch, useVibe } from "use-vibes";
import { Badge, Button, Field, Heading, Input, Row, Stack, Text } from "@vibes.diy/ui";
// Amounts are in the currency's smallest unit, the one Square charges in: cents for USD, whole yen
// for JPY. Show and read them through that unit, never a fixed 100.
const digits = (currency) => new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions().maximumFractionDigits;
const money = (amount, currency) => new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount / 10 ** digits(currency));
const toMinor = (text, currency) => Math.round(Number(text) * 10 ** digits(currency));
// The setup verdict, read on mount and again whenever the app comes back into view: the owner saves
// the token in another tab and returns, and Pay unlocks without a reload.
function useSetup() {
const [setup, setSetup] = useState(null); // { ready, owner, currency, items?, square?, ownerHandle?, appSlug? }
const latest = useRef(0);
useEffect(() => {
// Mount, focus and visibility reads overlap; only the latest one may land, or a slow stale
// answer overwrites a fresh one.
const load = () => {
const n = ++latest.current;
apiFetch("/setup").then((r) => r.json()).catch(() => ({ ready: false, owner: false })).then((s) => { if (n === latest.current) setSetup(s); });
};
load();
const onVisible = () => { if (document.visibilityState === "visible") load(); };
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("focus", load);
return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
}, []);
return setup;
}
// The item's steps: each one a link to the exact page, in that page's own words, with this vibe's
// real handle and slug filled in.
function setupSteps(vibe) {
return [
{ href: "https://developer.squareup.com/apps", link: "Square Developer Console", text: ": sign in with the Square account you sell with, press + to create an application, and give it any name." },
{ href: "https://developer.squareup.com/apps", link: "your application's Credentials", text: ": switch the toggle at the top to Production, press Show beside Production Access token, and copy it." },
{ href: `https://vibes.diy/vibes/mine/${vibe}/settings`, link: "this vibe's Settings tab", text: `: under Secrets, enter SQUARE_ACCESS_TOKEN, paste the token, and save. Or run npx vibes-diy secrets set SQUARE_ACCESS_TOKEN --vibe ${vibe}.` },
];
}
// The Setup surface, the owner's alone.
function SetupScreen({ setup }) {
const item = setup.items.find((i) => i.id === "square-token");
return (
<Stack>
<Heading>Setup</Heading>
<Row gap={2}>
<Text>{item.label}</Text>
<Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : "To do"}</Badge>
</Row>
{setup.square && (
<Text>
Selling through {setup.square.business} · {setup.square.location} · {setup.currency}
{setup.square.sandbox ? " (Square sandbox)" : ""}
</Text>
)}
{item.problem && <Text>Square said: {item.problem}</Text>}
<ol>
{setupSteps(`${setup.ownerHandle}/${setup.appSlug}`).map((step) => (
<li key={step.link}>
Open <a href={step.href} target="_blank" rel="noreferrer">{step.link}</a>
{step.text}
</li>
))}
</ol>
<Text muted>
The token stays on the server. Your first sale is real money: buy your cheapest item once and refund it from your Square Dashboard to see the whole trip.
</Text>
</Stack>
);
}
// The owner's price list. Each save is a `product` record that access.js takes from the owner alone,
// and the price on it is the price /checkout charges.
function Products({ products, currency }) {
const { database } = useFireproof("orders");
const [title, setTitle] = useState("");
const [price, setPrice] = useState("");
const [error, setError] = useState(null);
// A put lands on this device at once; the server's verdict arrives on its receipt. A refused save
// converges back to what the server holds, and the owner gets their draft and the reason back.
const save = async (product, undo) => {
setError(null);
const { synced } = await database.put(product);
synced.on((s) => {
if (s.status !== "refused") return;
undo?.();
setError(s.message ?? "That did not save: a product needs a name and a price like 12.50.");
});
};
const add = () => {
const amount = toMinor(price, currency);
if (!title.trim() || !Number.isInteger(amount) || amount <= 0) return setError("Give it a name and a price like 12.50.");
const product = { _id: "product-" + crypto.randomUUID(), type: "product", title: title.trim(), amount, currency, onSale: true, createdAt: Date.now() };
// The form empties as the owner presses Add; the draft comes back only if the write is refused.
const draft = price;
setTitle("");
setPrice("");
save(product, () => { setTitle(product.title); setPrice(draft); });
};
return (
<Stack>
<Heading>{products.length === 0 ? "Add the first thing you sell" : "Products"}</Heading>
<Row gap={2}>
<Field label="Name">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={title} onChange={setTitle} placeholder="Box of six croissants" />}</Field>
<Field label="Price">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={price} onChange={setPrice} placeholder="18.00" inputMode="decimal" />}</Field>
<Button onClick={add}>Add</Button>
</Row>
{error && <Text>{error}</Text>}
{products.map((p) => (
<Row key={p._id} gap={2}>
<Text>{p.title} · {money(p.amount, currency)}</Text>
<Button variant="quiet" onClick={() => save({ ...p, onSale: !p.onSale })}>{p.onSale ? "Take off sale" : "Put on sale"}</Button>
</Row>
))}
</Stack>
);
}
export default function App() {
const { me } = useVibe("orders");
const { useLiveQuery } = useFireproof("orders");
const { docs: orders } = useLiveQuery("type", { key: "order" });
const { docs: products } = useLiveQuery("type", { key: "product" });
const setup = useSetup();
const [picked, setPicked] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const [back, setBack] = useState(null); // the order the buyer just came back from
const [guestKey] = useState(() => {
try {
const kept = localStorage.getItem("guestKey") ?? "guest-" + crypto.randomUUID();
localStorage.setItem("guestKey", kept);
return kept;
} catch {
return "guest-" + crypto.randomUUID();
}
});
// Back from Square: the address names the order. Read the hash now AND on every change, since the
// wrapper can hand it over just after the first render, and keep the order id once the hash is cleared.
useEffect(() => {
const arrive = () => {
const orderId = new URLSearchParams(location.hash.slice(1)).get("paid");
if (!orderId) return;
history.replaceState(null, "", location.pathname);
setBack({ orderId, status: "checking" });
};
arrive();
window.addEventListener("hashchange", arrive);
return () => window.removeEventListener("hashchange", arrive);
}, []);
// /confirm reads Square's copy of the order. A guest's live query never holds the order (only the
// owner reads orders), so while it is pending the page asks again on a short backoff and whenever it
// comes back into view, and stops once it reads paid.
const backId = back?.orderId;
useEffect(() => {
if (!backId) return;
let done = false;
let timer;
let asking = false; // one ask at a time, so a focus during a slow answer starts no second chain
let delay = 3000;
const ask = () => {
if (asking) return;
asking = true;
clearTimeout(timer);
apiFetch("/confirm", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ orderId: backId }) })
.then((r) => r.json())
.catch(() => ({}))
.then((d) => {
asking = false;
if (done) return;
const status = d.status ?? "pending";
setBack({ orderId: backId, status });
if (status !== "pending") return (done = true);
timer = setTimeout(ask, delay);
delay = Math.min(delay * 2, 60000);
});
};
const onVisible = () => { if (!done && document.visibilityState === "visible") ask(); };
ask();
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("focus", onVisible);
return () => {
done = true;
clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("focus", onVisible);
};
}, [backId]);
// `lines` is a bag: [{ productId, qty }]. The page names products and quantities, never a price.
const pay = async (lines) => {
setError(null);
const tab = window.open("", "_blank"); // opened inside the click, before any await, so it is not a popup
if (!tab) return setError("Your browser blocked the Square tab. Allow pop-ups for this page and press Pay again.");
setBusy(true);
try {
const res = await apiFetch("/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ lines, guestKey }) });
const data = await res.json();
if (!data.ok) {
tab.close();
setError(data.reason === "unknown-item" ? "Something in your order is no longer for sale. Refresh and try again." : data.message ?? "Checkout is not open yet.");
return;
}
tab.location.href = data.url;
} finally {
setBusy(false);
}
};
const ready = setup?.ready === true;
const currency = setup?.currency ?? "USD";
const forSale = products.filter((p) => p.onSale);
const screen = picked ?? (setup?.owner && products.length === 0 ? "products" : "shop");
const backStatus = back && (orders.find((o) => o._id === back.orderId)?.status ?? back.status);
return (
<Stack>
<Row gap={2}>
<Button variant="quiet" onClick={() => setPicked("shop")}>Shop</Button>
{setup?.owner && <Button variant="quiet" onClick={() => setPicked("products")}>Products</Button>}
{setup?.owner && <Button variant="quiet" onClick={() => setPicked("orders")}>Orders</Button>}
{setup?.owner && (
<Button variant={ready ? "quiet" : "solid"} onClick={() => setPicked("setup")}>
Setup{ready ? "" : " · to do"}
</Button>
)}
</Row>
{screen === "setup" && setup?.owner ? (
<SetupScreen setup={setup} />
) : screen === "products" && setup?.owner ? (
<Products products={products} currency={currency} />
) : screen === "orders" && setup?.owner ? (
<Stack>
<Heading>Orders</Heading>
{orders.length === 0 ? (
<Text muted>No orders yet. Each one shows here the moment someone pays.</Text>
) : (
<ul>
{orders.map((o) => (
<li key={o._id}>
{o.buyerHandle ? `@${o.buyerHandle}` : "Guest"} · {o.title} · {money(o.amount, o.currency)} · {o.status}
{o.status === "paid" ? " · Also in your Square Dashboard" : ""}
</li>
))}
</ul>
)}
</Stack>
) : (
<Stack>
<Heading>Preorder</Heading>
{back && <Text>{backStatus === "paid" ? "Paid. Thank you!" : backStatus === "checking" ? "Asking Square about your payment…" : "Your payment is still confirming. It shows here as paid in a moment."}</Text>}
{forSale.length === 0 ? (
<Text muted>{setup?.owner ? "Nothing is for sale yet. Add what you sell under Products." : "Nothing for sale yet."}</Text>
) : ready ? (
forSale.map((p) => (
<Row key={p._id} gap={2}>
<Text>{p.title}</Text>
<Button onClick={() => pay([{ productId: p._id, qty: 1 }])} disabled={busy} loading={busy}>
Pay {money(p.amount, currency)} — Square opens in a new tab
</Button>
</Row>
))
) : (
<Text muted>Checkout is not open yet.</Text>
)}
{error && <Text>{error}</Text>}
</Stack>
)}
</Stack>
);
}
A shop with a bag keeps this backend: its Pay sends every line of the bag to
/checkout in one call, and the payment link carries one Square line item per
bag line, so the buyer's receipt lists what they bought. The ui-storefront
block's page swaps its Place order for this pay.