Skip to content

Merchant โ†” Venue Association โ€” Design โ€‹

Date: 2026-04-22 Status: Approved Scope: services/api/auth/, apps/admin/, firestore.rulesBranch: claude/merchant-ad-placeholders-y2pxuBuilds on: Merchant Portal in Admin App (Phases 1โ€“2 + merchant create/edit/detail/directory; see docs/plans/can-we-create-a-smooth-beaver.md)


Summary โ€‹

Introduce a merchant-entity concept separate from user identity, and let admins associate venues with merchants. Today a "merchant" is a user (users/{uid} with role: 'merchant'); after this work, a merchant is a business entity (merchants/{merchantId}) that has one or more users and has one or more venues.

Ships the identifier separation (so future references are stable) and the admin-assigned venue workflow (picker + associate/disassociate). Does not ship the full multi-user-per-merchant model (Issue #231), self-service merchant signup (#136), or venue onboarding โ€” all intentionally deferred.

Motivation โ€‹

Consumer auth uses zero-knowledge encryption (passphrase = encryption key), which is wrong for business accounts that need standard password recovery and multi-user access. The preceding iterations gave merchants a role-gated portal inside the admin app. This iteration lets that portal carry real merchant configuration โ€” starting with venue ownership, which everything downstream (offers, placements, consumer-app tether, analytics) references.

The design separates merchant identity from user identity today so that when #231 (multi-user per merchant) lands, venue references don't need to be rewritten and security rules don't need to be inverted. Getting the identifier right now is ~10 minutes of work; retrofitting it later is a multi-week migration.


Decisions โ€‹

All clarifying questions resolved during brainstorming (2026-04-22):

QDecisionRationale
Q0 โ€” Merchant modelIntroduce stable merchantId identifier now; full entity + merchantUsers junction deferred to #231Schema correctness without scope explosion
Q1 โ€” Venue multiplicityStrict one venue = one merchant. Multi-merchant-per-venue not plannedFood-court case degrades into child-venues-per-stall (more accurate anyway)
Q2 โ€” Association workflowAdmin assigns now; merchant self-service claim deferredEvery merchant is admin-created today (#136 is the prerequisite for claim)
Q3 โ€” Venue sourcePick from existing venues only; venue onboarding ships as a separate follow-up featureKeeps this iteration's PR scoped; venue onboarding deserves its own review
Q4 โ€” Data migrationDelete the one test merchant; start freshNo real data to preserve; cleanest outcome
โ€” user โ†’ merchantStrict one-to-one (singular merchantId on user doc)Simplification user confirmed: no context-switcher UI needed

Design โ€‹

Section 1 โ€” Schema โ€‹

Three existing collections touched, one new.

New: merchants/{merchantId} โ€‹

Business-entity document. {merchantId} format: m_{nanoid(12)} โ€” self-generated, type-prefixed (e.g. m_V1StGXR8_Z5j).

merchants/{merchantId}
  businessName: string
  status: 'active' | 'pending_setup' | 'suspended'
  createdAt: Timestamp
  createdBy: string                 (uid of admin who created)
  venueIds: string[]                (denormalized โ€” mirrors venues.merchantId)
  ownerUserIds: string[]            (convenience โ€” today always length 1)

Two intentional denormalizations:

  • venueIds duplicates venues.merchantId. Lets admin UI render venue count / list without a collection scan. Authoritative source remains venues.merchantId; both sides always written in one batch.
  • ownerUserIds duplicates users.merchantId. Convenience for listing a merchant's users. Keeps array shape from day one so #231 doesn't require array-migration.

Both safe because all writes flow through server endpoints โ€” client cannot break the mirror.

Modified: users/{uid} (merchant role only) โ€‹

One new field: merchantId: string. businessName removed from user doc (moves to merchants doc). Firebase Auth displayName now tracks contactName (the person), not businessName (the business).

users/{uid}
  email, role, createdAt, createdBy   (unchanged)
  displayName                         (now = contactName, not businessName)
  merchantId: string                  NEW โ€” singular, never an array

Why no mirror of businessName: when #231 lands and one merchant has N users, each user's displayName should be their personal name. "Hi Cafe Luna" in a password-reset email to John Smith is wrong.

Modified: merchantProfiles/{uid} (scope narrowed) โ€‹

Keyed by uid. Drops businessName. Keeps per-user fields.

merchantProfiles/{uid}
  contactName: string
  phone: string
  notes: string
  status: 'pending_setup' | 'active' | ...

Mirrors the adminProfiles/{uid} pattern. Survives #231 cleanly as the "per-user role-detail within a merchant" doc.

Modified: venues/{venueId} (no structural change) โ€‹

venues.merchantId already exists. The field keeps its name and type; only its referent changes from "user uid" to "merchantId." Since there is no real venue data today, no migration is needed.

Relationship diagram โ€‹

         users/{uid}
              โ”‚ merchantId  (string, single)
              โ–ผ
     merchants/{merchantId}  โ—„โ”€โ”€โ”€โ”€ venues.merchantId
              โ”‚
              โ””โ”€โ”€โ–บ venueIds[], ownerUserIds[]  (denormalized)

Section 2 โ€” Security rules โ€‹

One helper function hides the userโ†’merchant indirection; other rules call it.

Helpers (add near top of firestore.rules) โ€‹

function userMerchantId() {
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.merchantId;
}

function isMerchantOf(merchantId) {
  return request.auth != null && userMerchantId() == merchantId;
}

When #231 introduces a full merchantUsers junction, only isMerchantOf needs to change โ€” all call sites migrate for free.

New: merchants/{merchantId} โ€‹

match /merchants/{merchantId} {
  allow read:                   if isAdmin() || isMerchantOf(merchantId);
  allow create, update, delete: if false;    // server-only via Admin SDK
}

Rules are defense-in-depth. Primary authorization is checkAdminRole() middleware on the server endpoints; rules are the second gate.

Modified: venues/{venueId} update rule โ€‹

// Before:  resource.data.merchantId == request.auth.uid
// After:   isMerchantOf(resource.data.merchantId)

Lantern-count-only carve-out (authenticated users bumping activeLanternCount) remains unchanged. Read, create, delete rules unchanged.

Cost note โ€‹

Every rule evaluation that hits isMerchantOf() costs 1 extra document read (the users/{uid} lookup). Cached within a request. Limit: max 10 get() calls per rule per request โ€” we use 1. Merchant-originated venue edits are rare (merchants don't flood writes); admin operations bypass rules via Admin SDK.


Section 3 โ€” API / server changes โ€‹

All new endpoints land in services/api/auth/. Spinning up a dedicated services/api/merchants/ service is premature (CLAUDE.md rule #8: new OpenAPI/Scalar mount, new port, new deploy wiring). Can split later when billing/team operations arrive.

Route inventory โ€‹

MethodRouteStatusPurpose
POST/auth/admin/users/merchantmodifiedCreate merchant (user + entity)
PATCH/auth/admin/users/merchant/:userIdmodifiedUpdate (routes fields to correct doc)
GET/auth/admin/merchantsnewList merchants (for MerchantsAll)
GET/auth/admin/merchants/:merchantIdnewMerchant detail (replaces client-side getMerchantData)
POST/auth/admin/merchants/:merchantId/venuesnewAssociate venue
DELETE/auth/admin/merchants/:merchantId/venues/:venueIdnewDisassociate venue

services/api/venues/ gets no changes โ€” the picker reuses existing read endpoints.

Create (POST /auth/admin/users/merchant) โ€” modified โ€‹

Atomic sequence:

  1. Validate body (zod โ€” unchanged fields)
  2. Mint merchantId = 'm_' + nanoid(12)
  3. Create Firebase Auth user
  4. Firestore batch:
    • merchants/{merchantId} โ€” businessName, status='pending_setup', ownerUserIds=[uid], venueIds=[], createdAt, createdBy
    • users/{uid} โ€” email, role='merchant', merchantId, createdAt, createdBy
    • merchantProfiles/{uid} โ€” contactName, phone, notes, status='pending_setup' (no businessName)
  5. Set Firebase Auth displayName = contactName
  6. Generate password reset link
  7. Send invite email (if sendInvite !== false)

Response: { uid, merchantId, emailSent, resetLink, wasPromotion }.

Rollback on failure of step 4: if Firestore batch fails after Auth user is created, the server must call auth.deleteUser(uid) before returning error. (Today's code lacks this rollback โ€” latent bug fixed while touching the file.)

Update (PATCH /auth/admin/users/merchant/:userId) โ€” modified โ€‹

Keyed by uid for URL compat. Field routing:

FieldWrites to
businessNamemerchants/{user.merchantId}
contactNamemerchantProfiles/{uid} + Firebase Auth displayName
phone, notesmerchantProfiles/{uid}

Server resolves merchantId from users/{uid}.merchantId on each request.

List (GET /auth/admin/merchants) โ€” new โ€‹

Query merchants collection (not users-with-role-merchant). Pagination via lastDoc cursor. Optional status filter. Server joins the primary owner (the first entry in ownerUserIds[] โ€” today always the creating user; when #231 lands, the "primary" concept may evolve) and returns their email + contactName to avoid N client queries.

Detail (GET /auth/admin/merchants/:merchantId) โ€” new โ€‹

Server parallel-fetches:

  • merchants/{merchantId}
  • All users where merchantId == :merchantId
  • All venues where documentId โˆˆ merchant.venueIds

Returns { merchant, owners, venues } โ€” one round-trip for the whole MerchantDetail page.

Associate venue (POST .../merchants/:merchantId/venues) โ€” new โ€‹

Body: { venueId: string }.

Guards:

  • venues/{venueId}.merchantId must be null/unset (enforces Q1 strict one-to-one).
  • merchants/{merchantId} must exist.

Atomic batch:

  • venues/{venueId}.merchantId = merchantId
  • merchants/{merchantId}.venueIds arrayUnion venueId

Returns 409 Conflict if venue is already associated with a different merchant.

Disassociate venue (DELETE .../merchants/:merchantId/venues/:venueId) โ€” new โ€‹

Guard: venues/{venueId}.merchantId must currently equal :merchantId (prevents accidental cross-merchant clobber). Atomic batch removes both sides.

Promotion path (existing-user โ†’ merchant) โ€‹

The endpoint's existing behavior: if the email matches an existing user, promote them (wasPromotion: true); otherwise fall through to fresh-create. Preserved in the new flow.

Promotion sequence:

  1. Look up user by email โ†’ get existing uid
  2. Guard: if users/{uid}.merchantId already set, reject 409 โ€” honors "user โ†’ exactly one merchant."
  3. Mint merchantId
  4. Firestore batch (same fields as fresh-create, with users/{uid} merged rather than created):
    • merchants/{merchantId} โ€” businessName, status='pending_setup', ownerUserIds=[uid], venueIds=[], createdAt, createdBy
    • users/{uid} โ€” merge role='merchant' + merchantId (other fields preserved)
    • merchantProfiles/{uid} โ€” contactName, phone, notes, status='pending_setup' (merge if somehow already present)
  5. Set Firebase Auth displayName = contactName (if changed)
  6. No Auth user create, no password reset (existing user already has login)
  7. Return { uid, merchantId, emailSent: false, resetLink: null, wasPromotion: true }

Cross-cutting โ€‹

  • All routes gated by existing checkAdminRole() middleware.
  • App Check inherited (per commit 4193cb3).
  • OpenAPI: extend services/api/auth/openapi.json with new routes (CLAUDE.md rule #8).

Section 4 โ€” Admin UI โ€‹

Five surfaces change.

4.1 Route key: :merchantId instead of :userId โ€‹

/merchants/:merchantId now expects an m_* id. CreateMerchantForm navigates using the new merchantId from the create response. MerchantsAll rows link via merchantId. No legacy URL compatibility needed (single test merchant being deleted per Q4).

4.2 MerchantDetail โ€” replace Venue placeholder โ€‹

The Venue PlaceholderSection becomes a real section. Venue associations are immediate, not part of Edit/Save โ€” Gmail-labels mental model. Edit/Save only governs field edits (businessName, contactName, phone, notes).

View mode:

โ”Œโ”€ Venues โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Cafe Luna           123 Main St        [Remove] โ”‚
โ”‚  Cafe Luna - West    456 Oak Ave        [Remove] โ”‚
โ”‚                                                   โ”‚
โ”‚  [+ Associate venue]                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

"Remove" hits DELETE .../venues/:venueId with a confirm dialog. "+ Associate venue" opens AdminVenuePicker inline.

4.3 New: AdminVenuePicker โ€‹

Lives at apps/admin/src/components/venues/AdminVenuePicker.jsx. Admin context (search-first, no map, no geolocation) differs enough from the consumer VenuePicker (location-aware, map view, category filters) that a new component is cleaner than multi-purposing the existing one.

Shape:

โ”Œโ”€ Associate venue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  [ Search venues by name or address...         ] โ”‚
โ”‚                                                   โ”‚
โ”‚  โ—ฏ Cafe Luna         123 Main St    [available]  โ”‚
โ”‚  โ—‰ Cafe Luna - East  789 East Rd    [available]  โ”‚
โ”‚  โ—Œ Bar Saturn        555 Moon Way   [claimed]    โ”‚ โ† disabled
โ”‚  โ—Œ Cafe Luna - West  456 Oak Ave    [this merchant] โ”‚ โ† disabled
โ”‚                                                   โ”‚
โ”‚  [Cancel]                            [Associate] โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Three result states:

  • available โ€” venues.merchantId is null; selectable
  • claimed โ€” already associated with some merchant; disabled
  • this merchant โ€” already associated with the current merchant; disabled

Transparency beats trickery: showing claimed venues (disabled, labeled) beats hiding them. Common troubleshooting: "why can't I find Cafe Luna?" โ†’ "it's claimed."

Backend: reuses existing venue-search endpoints in services/api/venues/ or venueService.js. New component layers claim-status overlay on top.

4.4 MerchantsAll reshape โ€‹

Currently calls fetchUsers({ roleFilter: 'merchant' }). Switch to GET /auth/admin/merchants.

Row contents change:

  • Before: uid, email, businessName (from user doc)
  • After: businessName (from merchant doc), primary owner email (joined server-side), status, venue count

Venue count is a new at-a-glance signal โ€” "which merchants need venues assigned?"

Click row โ†’ /merchants/:merchantId.

4.5 CreateMerchantForm post-create navigation โ€‹

Minor change: response now includes merchantId; use it for navigate(\/merchants/${merchantId}`). location.state` unchanged (reset link + banner).

File-level touch points โ€‹

FileChange
apps/admin/src/components/merchants/MerchantDetail.jsxReplace Venue placeholder; route key change
apps/admin/src/components/merchants/MerchantsAll.jsxQuery merchants not users; add venue-count column
apps/admin/src/components/merchants/CreateMerchantForm.jsxConsume new merchantId field
apps/admin/src/components/venues/AdminVenuePicker.jsxNew
apps/admin/src/firebase.jsPoint getMerchantData at new GET endpoint; add venue-assoc helpers
apps/admin/src/App.jsxRoute param name (cosmetic)

Section 5 โ€” Merchant creation flow (end-to-end) โ€‹

Supplements Section 3 with failure modes and downstream effects.

Failure modes and recovery โ€‹

Step failsEffectRecovery
3 (Auth create)No user, no Firestore docsSafe. Admin retries.
4 (Firestore batch)Orphaned Auth userServer must auth.deleteUser(uid) before returning error. Latent bug fixed while touching the file.
5 (displayName set)User + Firestore docs exist, displayName missingTolerate. Next PATCH sets it.
6 (reset link)Everything exists, no resetLinkTolerate. Return resetLink: null.
7 (email send)Everything exists, resetLink in responseTolerate. Return emailSent: false. UI surfaces resetLink as copy-fallback.

displayName = contactName โ€” downstream effects โ€‹

Firebase Auth displayName changes from businessName โ†’ contactName. Effects:

  • Email templates. "Hi John" in password-reset/verification emails instead of "Hi Cafe Luna." Correct.
  • Firebase Auth console. Shows person name, not business name.
  • Client code reading auth.currentUser.displayName. Grep needed at implementation time. Any reliance on displayName-as-businessName gets rewritten to read merchants/{merchantId}.businessName.

Edge cases deliberately out of scope โ€‹

  • Email change after creation (Firebase Auth verification flow non-trivial)
  • Delete / suspend merchant
  • Resend invite

Section 6 โ€” Deferred / future configs โ€‹

Split into three categories:

A โ€” Unlocked by this feature (ships next) โ€‹

#FeatureNotes
A1Address field on MerchantDetailMerchant-level correspondence address; distinct from venue physical address
A2Venue onboarding as its own featurePath 2 follow-up from Q3; proper create-venue flow with geocoding + dedup in services/api/venues/
A3Offers UI (#139)Was blocked on venue association
A4Consumer-app tether"View your offers at Cafe Luna" when merchant is signed in inside their venue
A5Overview metrics (real UI)Impressions/clicks/redemptions โ€” needs A3
A6Upgrade AdminVenuePicker to Path 1Add "Create new venue" button once A2 exists
A7Photo uploadScope decision at implementation time: venue-level (interior shots) vs merchant-level (logo)

B โ€” Designed-for-but-not-built (schema accommodates; UI/endpoints don't exist) โ€‹

#FeatureWhat this iteration preparedRef
B1Multi-user per merchantmerchantId on user doc; ownerUserIds[]; isMerchantOf() rule function#231
B2Merchant team management UIRoles (owner/manager/staff), permissions, invite flow#231
B3merchantUsers junction collectionReplaces ownerUserIds[] when membership becomes first-class#231
B4Merchant self-service signupUses same Firestore shape; adds onboarding flow#136
B5Self-service venue claimQ2's deferred path; admin approves merchant claimsnew issue
B6Delete / suspend merchantmerchants.status already has values; needs cascade + Auth disable
B7Resend invite flowMirror admin's /resend-setup
B8Email change on merchantFirebase Auth verification flow
B9Role change (promote/demote)First-class operation with audit shape
B10Audit trail for merchant actionsmerchantAuditLog/{id} subcollection#231
B11Route migration /users/merchant/* โ†’ /merchants/*Consolidate once user-vs-entity is real

C โ€” Explicitly not planned โ€‹

#DecisionWhy rejected
C1Many merchants per single venueFood-court case degrades into child-venues-per-stall
C2One user belonging to multiple merchantsUser confirmed: strict one-to-one. Could relax later if franchisee pattern emerges, not planned
C3merchantId as slug from businessNameIdentity-as-mutable is a bug factory
C4Kill merchantProfiles/{uid} collectionBreaks symmetry with adminProfiles; no structural benefit
C5Mirror businessName on user docBecomes semantically wrong when one merchant has multiple users

Acceptance criteria โ€‹

  • [ ] New merchant created via admin UI produces:
    • [ ] One merchants/{merchantId} doc with m_* id
    • [ ] users/{uid}.merchantId set
    • [ ] merchantProfiles/{uid} without businessName
    • [ ] Firebase Auth user with displayName = contactName
  • [ ] Admin can associate an available venue with a merchant via MerchantDetail
  • [ ] Admin can disassociate a venue
  • [ ] Claimed venues show as disabled in the picker with a visible state label
  • [ ] MerchantsAll queries merchants collection and shows businessName, owner email, status, venue count
  • [ ] MerchantDetail renders merchant + owners + venues in one round-trip
  • [ ] Firestore rules allow admin reads/writes; allow merchant reads of own merchant doc; deny client writes to merchants collection
  • [ ] Firestore rules allow merchant updates of venues where isMerchantOf(venue.merchantId)
  • [ ] Attempting to associate a venue that's already claimed returns 409
  • [ ] Attempting to promote a user who already has a merchantId returns 409
  • [ ] Test merchant from prior iterations deleted; fresh create produces clean docs

Out of scope โ€‹

  • Merchant self-service signup (#136)
  • Full merchantUsers junction + role/permissions system (#231)
  • Venue onboarding / create-new-venue flow
  • Self-service venue claim
  • Delete/suspend merchant
  • Resend invite
  • Email change
  • Offers UI (#139) โ€” unblocked after this
  • Consumer-app tether โ€” unblocked after this

Implementation notes โ€‹

  • nanoid is not currently a dep of services/api/auth/; add to package.json (small, well-maintained).
  • node --watch caveat (CLAUDE.md) โ€” new routes require auth-api restart; existing-route edits hot-reload.
  • Admin validation: npm run validate -- --workspace services/api/auth and npm run validate -- --workspace apps/web during iteration; full npm run validate before commit.
  • OpenAPI spec at services/api/auth/openapi.json must be updated for new routes per CLAUDE.md rule #8.

Built with VitePress