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:

  1. POST /api/checkout/initiate still 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).
  2. In addition, glue enqueues a simulate-paid job on the demo-paid-queue BullMQ queue with delay = 5_000ms.
  3. 5 seconds later, the worker flips the order to paid, fills in placeholder paid_currency / paid_amount / tx_hash, and enqueues apply-entitlements on the standard entitlements-queue — same handoff a real Bitcart complete webhook 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.tsdemo-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::resolveMerchantORDER BY createdAt ascending | | Live | demo stands at shop.openbitum.pw + demo.openbitum.pw |