Multi-merchant + demo mode
How to provision a second merchant on the same OpenBitum install,
and how to use the demo_mode flag
to run a storefront end-to-end without spending crypto.
Recognise the single-merchant assumption (and where it bites)
OpenBitum's MVP schema supports multi-tenant cleanly at the data
layer — orders.merchant_id, products.merchant_id, and a merchants
table that takes more than one row. The single-merchant gotcha is on
the dashboard side:
// apps/glue/src/app.ts
const resolveMerchant = async (app) => {
const [row] = await app.db
.select({ id: merchants.id })
.from(merchants)
.orderBy(merchants.createdAt)
.limit(1);
return row?.id ?? "merchant-not-configured";
};
The merchant dashboard at app.openbitum.pw/dashboard/* filters every
view by opts.merchantId. resolveMerchant is called once at boot;
it picks the oldest row in merchants (by createdAt) so adding
a second merchant doesn't accidentally take over the dashboard. If
you genuinely want a different merchant to "own" the dashboard, swap
the ORDER BY or hardcode the id via env.
Provision a second merchant
Two safe ways:
A. Glue admin API (recommended once you have several merchants):
There is no dashboard UI for creating merchants in the MVP — the single-tenant assumption above is the reason. Stick to SQL until multi-tenant dashboard work lands.
B. Direct SQL (what the seed does):
INSERT INTO merchants
(id, name, bitcart_store_id, wallet_xpubs, webhook_secret, demo_mode)
VALUES
('demo_merchant_0000000000002',
'Nova SaaS (demo)',
'demo-bitcart-store',
'{}'::jsonb,
encode(gen_random_bytes(24), 'hex'),
true); -- demo_mode column — see step 3
Then attach products by setting products.merchant_id to the new id.
Toggle demo_mode on a merchant
ALTER TABLE merchants
ADD COLUMN IF NOT EXISTS demo_mode boolean DEFAULT false NOT NULL;
UPDATE merchants SET demo_mode = true
WHERE id = 'demo_merchant_0000000000001'; -- or any merchant id
packages/db/migrations/0003_demo_mode.sql is the shipped migration.
What demo_mode actually does in glue
When merchant.demo_mode = true:
POST /api/checkout/initiatestill creates a real Bitcart invoice (real BTC payment address derived from the merchant's xPub if one is set — but no one's going to pay it).- In addition, glue enqueues a
simulate-paidjob on thedemo-paid-queueBullMQ queue withdelay = 5_000ms. - 5 seconds later, the worker flips the order to
paid, fills in placeholderpaid_currency/paid_amount/tx_hash, and enqueuesapply-entitlementson the standardentitlements-queue— same handoff a real Bitcartcompletewebhook would do.
// apps/glue/src/workers/demo-paid.ts (excerpt)
export const startDemoPaidWorker = (deps): Worker =>
new Worker(DEMO_PAID_QUEUE, async (job) => {
const { orderId } = job.data;
const [row] = await deps.db.select().from(orders)
.where(eq(orders.id, orderId)).limit(1);
if (!row || row.status === "paid") return;
await deps.db.update(orders).set({
status: "paid",
paidAt: new Date(),
paidCurrency: row.paidCurrency ?? "USDC_BASE",
txHash: row.txHash
?? "0xdemo-mode-simulated-paid-no-actual-tx-was-broadcast",
}).where(eq(orders.id, row.id));
await deps.entitlementsQueue.add("apply-entitlements", { orderId },
{ attempts: 5, backoff: { type: "exponential", delay: 1000 },
removeOnComplete: true, removeOnFail: false });
}, { connection: deps.connection });
Poll the order from the buyer's UI
The original GET /api/orders/:id endpoint is auth-gated (owner-only)
— it works for the merchant dashboard but not for an anonymous buyer
on the checkout/pay page. There's a public, minimal sibling for
exactly that use case:
GET /api/checkout/status?orderId=<id>
→ 200 { "status": "pending" | "paid" | "expired", "paidAt": null | iso }
→ 404 { "error": "not_found" }
→ 400 { "error": "missing_orderId" }
Use it on the app.openbitum.pw/checkout/<productId>/pay page (or
your own storefront) to flip a UI badge from "Waiting for payment" to
"Paid" without the buyer having to sign in.
// minimal client poll
const tickPaid = async (orderId: string) => {
for (let i = 0; i < 30; i++) {
const r = await fetch(`/api/checkout/status?orderId=${orderId}`);
const { status } = await r.json();
if (status === "paid") return true;
await new Promise((res) => setTimeout(res, 500));
}
return false;
};
Verify end-to-end
Backing e2e specs in tests/e2e/:
| Spec | What it covers |
|---|---|
| qa-demo-paid-flow-001 | Asserts a demo-mode order reaches paid within 15 s via /api/checkout/status. |
| qa-demo-shop-buy-flow-001 | Browser-driven happy path: shop product → Buy CTA → checkout → 5 s → portal /account renders for the signed-in buyer. |
Both run against live prod and pass; they're the canary for the demo_mode plumbing.
Two-merchant install summary
After this tutorial you should have:
| Concern | Where it lives |
|---|---|
| Schema | merchants.demo_mode boolean (migration 0003) |
| Glue worker | apps/glue/src/workers/demo-paid.ts — demo-paid-queue + simulate-paid job |
| Schedule | apps/glue/src/routes/checkout.ts enqueues with 5 s delay when merchant.demoMode |
| Public status | GET /api/checkout/status?orderId=<id> — unauth, returns {status, paidAt} |
| Dashboard merchant pick | apps/glue/src/app.ts::resolveMerchant — ORDER BY createdAt ascending |
| Live | demo stands at shop.openbitum.pw + demo.openbitum.pw |