CroaCard logo

C4 Architecture

System architecture diagrams plus native scanner view

Level 1 — System Context
Level 2 — Containers
Level 3 — Components
Native Scanner Architecture
Data Flows
Deployment

Level 1 — System Context

CroaCard and the people & external systems it interacts with.

100%

Level 2 — Container Diagram

Major deployable units, services, and data stores within the CroaCard platform.

100%

Level 3 — Component Diagram (Backend)

Detailed breakdown of backend services, routes, and their relationships.

100%

Native Scanner Architecture

Camera control, scan processing, callable functions, and Apple/Google Wallet update path. Stamps require a transaction amount at scan time.

100%

Data Flows

Key end-to-end flows through the system.

Pass Creation Flow

  1. Merchant fills PassWizard (template, colours, assets, rewards)
  2. SPA POSTs to /api/customer-pass/create
  3. customerPassCoreService orchestrates creation
  4. passGenerationService loads template, substitutes fields, processes assets, signs .pkpass
  5. Uploads .pkpass to Firebase Storage
  6. Atomic transaction: writes customerPass doc + giftCardLedger issuance entry (if gift card) — both succeed or neither does
  7. Issuance ledger includes amountMinor, currency, ledgerVersion, stripePaymentIntentId (when Stripe-backed)
  8. Returns download URL to frontend

Scan / Visit Flow

  1. Merchant opens ScannerTab, camera reads QR
  2. checkPassType (callable) → returns reward mode; stamps require transaction amount
  3. updateVisit (callable) — thin orchestrator delegates to extracted services
  4. Points: pointsService.calculatePoints — cumulative spending with fractional rollover
  5. Stamps: stampService.processStamp — cap enforcement, reward redemption detection
  6. Gift cards: giftCardService.processGiftCardSpend — balance deduction + giftCardLedger entry (atomic in txn)
  7. Firestore transaction: all reward mutations + computeChangeMessages written atomically
  8. scanResponseBuilder.buildResponse constructs response from projected state (no re-read)
  9. Fire-and-forget: evaluateRetentionAfterVisit with stateBefore/stateAfter
  10. Fire-and-forget: attribution check against recent retentionNotifications (48h window)
  11. Customer profile sync: visitsCount, pointsBalance, lastVisit
  12. onCustomerPassUpdated trigger sends APNs via notificationLog subcollection (rate-limited 500ms)
  13. Apple Wallet refetches .pkpass via webServiceURL
  14. onPassScanned trigger logs scan event

Coupon Redemption Flow (Tap-to-Pay)

  1. Customer presents coupon QR code at POS
  2. Merchant scans QR via ScannerTab
  3. checkPassType (callable) returns reward mode + requiresPayment: true
  4. PaymentModalAdapter shown for card payment
  5. /v1/transactions/prepare-payment validates coupon via couponValidator, calculates discount (percentage / fixed / freeItem)
  6. /v1/transactions/create-payment-intent creates Stripe Terminal PaymentIntent via Connect account with TransactionMetadata (merchantId, couponSerial, customerPassId)
  7. /v1/transactions/terminal-connection-token returns Stripe Terminal connection token + location
  8. Stripe Terminal SDK collects card payment on reader
  9. payment_intent.succeeded webhook (or client fallback) triggers /v1/transactions/finalize-coupon-redemption
  10. redeemCoupon marks coupon as redeemed in Firestore
  11. Transaction doc logged to transactions collection

Gift Card Purchase Flow

  1. Frontend calls createPayment callable with paymentType: 'giftcard' + customerPassId
  2. stripeService.createPaymentIntent writes Stripe metadata: croacard_merchant_id, croacard_customer_pass_id, croacard_payment_type
  3. Writes giftCardPayments/{paymentIntentId} mapping doc (immutable, server-only)
  4. Returns clientSecret for Stripe Elements payment flow

Gift Card Refund Flow

  1. Stripe fires charge.refunded webhook (may contain multiple partial refunds)
  2. stripeWebhook reads croacard_customer_pass_id from charge metadata
  3. Iterates charge.refunds.data, skips already-processed by stripeRefundId
  4. Looks up giftCardPayments mapping for amountPaid cap
  5. Computes sumRefundedSoFar from ledger; caps each refund so total ≤ paid
  6. Each refund processed in its own Firestore transaction: updates balance + writes ledger
  7. Balance after refund cannot exceed originalBalance

Subscription Flow

  1. Merchant clicks upgrade in Home or SettingsTab
  2. createCheckoutSession (callable) returns Stripe Checkout URL
  3. Merchant completes payment on Stripe
  4. SPA lands on /dashboard?subscription=success and calls syncSubscriptionStatus + short poll
  5. subscriptionWebhook claims Stripe event id for idempotency before processing
  6. checkout.session.completed / customer.subscription.updated writes subscription to Firestore
  7. Plan fields update only when Stripe price id resolves; unresolved price logs critical alert and preserves prior plan
  8. Plan transitions write retry-safe planChangeAudit rows keyed by Stripe event id
  9. invoice.payment_succeeded updates lastPaymentDate; invoice.payment_failed flags merchant

Retention Flow (Hybrid Wallet + Email)

  1. Wallet channel (inline, synchronous):
  2. updateVisit calls computeChangeMessages inside the Firestore transaction
  3. Writes lifecycleChangedFields + lifecycleChangeMessages atomically with points/stamps/balance
  4. onCustomerPassUpdated trigger queries notificationLog subcollection for 500ms rate-limit
  5. Sends APNs silent push; logs to notificationLog (avoids self-trigger loop)
  6. Device fetches pass; applyChangeMessages injects changeMessage (with %@) into pass.json fields
  7. Apple Wallet diffs old vs new pass, displays lock screen banner
  8. Email channel (async, event-driven):
  9. evaluateRetentionAfterVisit writes retentionEvent with embedded PassSnapshotForOutbox
  10. onRetentionEventCreated trigger invokes retentionOrchestrator
  11. buildContext uses embedded snapshot — skips pass doc read (saves 1 Firestore read/invocation)
  12. Orchestrator skips wallet channel if lifecycleChangeMessages already written inline
  13. Routes to email channel via channelRouter (behind RETENTION_EMAIL_ENABLED flag)
  14. frequencyController uses shared toDate from dateHelpers for throttle checks
  15. mapTriggerToEventType centralised in retentionHelpers (used by eventWriter + orchestrator)

Wallet Lifecycle changeMessage Flow

  1. Scan triggers updateVisit callable
  2. computeChangeMessages(before, after) diffs watched fields: currentPoints, currentStamps, currentBalance, tier, status
  3. Produces changeMessages map with %@ placeholders (e.g. "Your points balance is now %@.")
  4. Written to customerPass doc in same Firestore transaction as reward updates
  5. onCustomerPassUpdated fires APNs → device fetches pass
  6. Pass fetch route reads lifecycleChangeMessages from doc, passes to generateCustomerPassFromTemplate
  7. applyChangeMessages injects changeMessage onto matching pass.json fields by field.key
  8. Invariant: if changedFields exist but no matching field key found, logs wallet_change_message_mismatch warning
  9. Apple Wallet compares old vs new pass, substitutes %@ with new value, shows banner
  10. Lifecycle fields overwritten on next scan (not cleared after fetch)

Bulk Distribution Flow

  1. Merchant uploads CSV via DistributeTab
  2. processCsvUpload (callable) parses CSV rows
  3. For each row: creates customer doc if needed
  4. Calls customerPassCoreService to generate pass
  5. Sends claim link via email/SMS (if configured)
  6. Writes batch results to Firestore

Analytics Flow

  1. AnalyticsTab queries Firestore collections
  2. Aggregates: issued/claimed passes, scan counts, revenue
  3. AnalyticsService provides enhanced insights
  4. Gift card sales tracking and statistics
  5. Charts rendered via frontend components

Gift Card Reconciliation Flow

  1. reconcileGiftCardBalances fires every 24h (scheduled)
  2. For each merchant, fetches all gift card customerPasses
  3. For each pass, sums all giftCardLedger entries
  4. expectedBalance = totalCredits - totalDebits (pure ledger-driven)
  5. Compares to currentBalance on the pass document
  6. If mismatch detected: writes reconciliationAlerts doc with delta and pass details
  7. Legacy gift cards (pre-issuance entries) handled gracefully

Gift Card CSV Export Flow

  1. Merchant navigates to /reports/gift-cards in the SPA
  2. Selects report type and optional date range
  3. SPA calls exportGiftCardReport (callable)
  4. Security: merchantId derived from auth.uid — client input ignored
  5. All monetary values exported as integer pence (amountMinor) + currency column + formatted convenience column
  6. CSV uploaded to Cloud Storage at exports/{merchantId}/
  7. Returns 1-hour signed download URL, auto-opens in browser
  8. Three report types: Liability Summary, Ledger Detail, Stripe Reconciliation

Webhook Idempotency Flow

  1. Stripe fires webhook event (e.g. payment_intent.succeeded)
  2. webhookHandler verifies Stripe signature
  3. Attempts create() on webhookEventsProcessed/{stripe_{eventId}}
  4. If doc already exists (ALREADY_EXISTS code 6) → returns 200 duplicate:true, skips processing
  5. Dedup doc stores: processedAt, eventType, provider, ttlAt (30 days for Firestore TTL auto-cleanup)
  6. On processing failure: deletes dedup doc to allow Stripe retry
  7. Survives cold starts — Firestore-backed, not in-memory

APNs Rate-Limiting Flow

  1. onCustomerPassUpdated fires on any customerPass doc change
  2. Queries notificationLog subcollection (ordered by sentAt desc, limit 1)
  3. If last notification was <500ms ago → skip (prevents APNs flood from rapid writes)
  4. Sends APNs push (WalletCore or legacy key based on passTypeIdentifier)
  5. Logs to notificationLog subcollection (not parent doc — avoids self-trigger loop)
  6. If lifecycleChangedFields present, writes retentionNotifications doc for attribution analytics

Scheduler Batch Optimisation

  1. runScheduledRetention fires every 24h
  2. For each merchant + pass template, fetches all active customerPasses
  3. Collects unique customerId values from results
  4. Batch-fetches all customer docs with db.getAll(...refs) (single round-trip)
  5. Iterates passes using pre-fetched customer map (eliminates N+1 reads)
  6. Reduces Firestore reads by ~90% for large merchant pass bases

Deployment Topology

Where each component runs in production.

ComponentPlatformRegionRuntime
Web App (+ Reports UI)Firebase HostingGlobal CDNStatic (Vite)
API + Callables + TriggersCloud Functions (2nd Gen)europe-west2Node.js 22
exportGiftCardReportCloud Functions (2nd Gen) — callableeurope-west2Node.js 22, 512 MiB
createPaymentCloud Functions (2nd Gen) — callableeurope-west2Node.js 22
reconcileGiftCardBalancesCloud Functions (1st Gen) — scheduled 24heurope-west2Node.js 22
Scheduled FunctionsCloud Functions (1st Gen)europe-west2Node.js 22
FirestoreCloud Firestoreeurope-west2Managed
  ↳ giftCardLedgerSubcollection of merchantsImmutable, server-write
  ↳ giftCardPaymentsSubcollection of merchantsImmutable, server-write
  ↳ reconciliationAlertsSubcollection of merchantsServer-write, merchant-read
  ↳ webhookEventsProcessedTop-level collectionDedup docs, TTL 30d
  ↳ notificationLogSubcollection of customerPassesAPNs rate-limit log
  ↳ attributedVisitsSubcollection of merchants48h attribution window
StorageFirebase StorageDefault bucketManaged
  ↳ exports/{merchantId}/CSV export filesSigned URLs, 1h expiry
Stripe WebhooksStripe to Cloud Functionseurope-west2HTTPS
  ↳ charge.refundedStripe Connect webhookPartial refund handler
onRetentionEventCreatedCloud Functions (2nd Gen) — Firestore triggereurope-west2Node.js 22
  ↳ retentionOrchestratorChannel routing (wallet skip / email)Hybrid lifecycle

Deployment Topology

Canonical deployment targets and runtimes. Do not deploy docs to croacard-overview.web.app.

SiteDomainContents
Main Apphttps://croacard.web.appSPA + API rewrites
Overview Docshttps://overview.croacard.co.ukC4 architecture static page
(Do not deploy)https://croacard-overview.web.appLegacy preview; keep disabled
ComponentPlatformRegionRuntime
Web AppFirebase HostingGlobal CDNStatic (Vite build)
API + Callables + TriggersCloud Functions (2nd Gen)europe-west2Node.js 22
Scheduled FunctionsCloud Functions (1st Gen)europe-west2Node.js 22
FirestoreCloud Firestoreeurope-west2Managed
StorageFirebase StorageDefault bucketManaged
Stripe WebhooksStripe → Cloud Functionseurope-west2HTTPS