System architecture diagrams with the ScannerMode runtime model
CroaCard and the people & external systems it interacts with.
Major deployable units, services, and data stores within the CroaCard platform.
Detailed breakdown of backend services, routes, and their relationships.
`ScannerMode` is the single scanner host. It starts from `/home` as a runtime mode (not a scanner route), then handles camera, scan processing, and wallet updates.
Key end-to-end flows through the system.
PassWizard (template, colours, assets, rewards)/api/customer-pass/createcustomerPassCoreService orchestrates creationpassGenerationService loads template, substitutes fields, processes assets, signs .pkpass.pkpass to Firebase StoragecustomerPass doc + giftCardLedger issuance entry (if gift card) — both succeed or neither doesamountMinor, currency, ledgerVersion, stripePaymentIntentId (when Stripe-backed)/home) and starts scan modeScannerMode mounts as the single scanner host (runtime mode, not a scanner route), camera reads QRcheckPassType (callable) → returns reward mode; stamps require transaction amountupdateVisit (callable) — thin orchestrator delegates to extracted servicespointsService.calculatePoints — cumulative spending with fractional rolloverstampService.processStamp — cap enforcement, reward redemption detectiongiftCardService.processGiftCardSpend — balance deduction + giftCardLedger entry (atomic in txn)computeChangeMessages written atomicallyscanResponseBuilder.buildResponse constructs response from projected state (no re-read)evaluateRetentionAfterVisit with stateBefore/stateAfterretentionNotifications (48h window)visitsCount, pointsBalance, lastVisitonCustomerPassUpdated trigger sends APNs via notificationLog subcollection (rate-limited 500ms).pkpass via webServiceURLonPassScanned trigger logs scan eventScannerMode (same host)checkPassType (callable) returns reward mode + requiresPayment: truePaymentModalAdapter shown for card payment/v1/transactions/prepare-payment validates coupon via couponValidator, calculates discount (percentage / fixed / freeItem)/v1/transactions/create-payment-intent creates Stripe Terminal PaymentIntent via Connect account with TransactionMetadata (merchantId, couponSerial, customerPassId)/v1/transactions/terminal-connection-token returns Stripe Terminal connection token + locationpayment_intent.succeeded webhook (or client fallback) triggers /v1/transactions/finalize-coupon-redemptionredeemCoupon marks coupon as redeemed in Firestoretransactions collectioncreatePayment callable with paymentType: 'giftcard' + customerPassIdstripeService.createPaymentIntent writes Stripe metadata: croacard_merchant_id, croacard_customer_pass_id, croacard_payment_typegiftCardPayments/{paymentIntentId} mapping doc (immutable, server-only)clientSecret for Stripe Elements payment flowcharge.refunded webhook (may contain multiple partial refunds)stripeWebhook reads croacard_customer_pass_id from charge metadatacharge.refunds.data, skips already-processed by stripeRefundIdgiftCardPayments mapping for amountPaid capsumRefundedSoFar from ledger; caps each refund so total ≤ paidoriginalBalanceHome or SettingsTabcreateCheckoutSession (callable) returns Stripe Checkout URL/dashboard?subscription=success and calls syncSubscriptionStatus + short pollsubscriptionWebhook claims Stripe event id for idempotency before processingcheckout.session.completed / customer.subscription.updated writes subscription to FirestoreplanChangeAudit rows keyed by Stripe event idinvoice.payment_succeeded updates lastPaymentDate; invoice.payment_failed flags merchantupdateVisit calls computeChangeMessages inside the Firestore transactionlifecycleChangedFields + lifecycleChangeMessages atomically with points/stamps/balanceonCustomerPassUpdated trigger queries notificationLog subcollection for 500ms rate-limitnotificationLog (avoids self-trigger loop)applyChangeMessages injects changeMessage (with %@) into pass.json fieldsevaluateRetentionAfterVisit writes retentionEvent with embedded PassSnapshotForOutboxonRetentionEventCreated trigger invokes retentionOrchestratorbuildContext uses embedded snapshot — skips pass doc read (saves 1 Firestore read/invocation)lifecycleChangeMessages already written inlinechannelRouter (behind RETENTION_EMAIL_ENABLED flag)frequencyController uses shared toDate from dateHelpers for throttle checksmapTriggerToEventType centralised in retentionHelpers (used by eventWriter + orchestrator)updateVisit callablecomputeChangeMessages(before, after) diffs watched fields: currentPoints, currentStamps, currentBalance, tier, statuschangeMessages map with %@ placeholders (e.g. "Your points balance is now %@.")customerPass doc in same Firestore transaction as reward updatesonCustomerPassUpdated fires APNs → device fetches passlifecycleChangeMessages from doc, passes to generateCustomerPassFromTemplateapplyChangeMessages injects changeMessage onto matching pass.json fields by field.keychangedFields exist but no matching field key found, logs wallet_change_message_mismatch warning%@ with new value, shows bannerDistributeTabprocessCsvUpload (callable) parses CSV rowscustomerPassCoreService to generate passAnalyticsTab queries Firestore collectionsAnalyticsService provides enhanced insightsreconcileGiftCardBalances fires every 24h (scheduled)customerPassesgiftCardLedger entriesexpectedBalance = totalCredits - totalDebits (pure ledger-driven)currentBalance on the pass documentreconciliationAlerts doc with delta and pass details/reports/gift-cards in the SPAexportGiftCardReport (callable)merchantId derived from auth.uid — client input ignoredamountMinor) + currency column + formatted convenience columnexports/{merchantId}/payment_intent.succeeded)webhookHandler verifies Stripe signaturecreate() on webhookEventsProcessed/{stripe_{eventId}}ALREADY_EXISTS code 6) → returns 200 duplicate:true, skips processingprocessedAt, eventType, provider, ttlAt (30 days for Firestore TTL auto-cleanup)onCustomerPassUpdated fires on any customerPass doc changenotificationLog subcollection (ordered by sentAt desc, limit 1)passTypeIdentifier)notificationLog subcollection (not parent doc — avoids self-trigger loop)lifecycleChangedFields present, writes retentionNotifications doc for attribution analyticsrunScheduledRetention fires every 24hcustomerPassescustomerId values from resultsdb.getAll(...refs) (single round-trip)Where each component runs in production.
| Component | Platform | Region | Runtime |
|---|---|---|---|
| Web App (+ Reports UI) | Firebase Hosting | Global CDN | Static (Vite) |
| API + Callables + Triggers | Cloud Functions (2nd Gen) | europe-west2 | Node.js 22 |
| exportGiftCardReport | Cloud Functions (2nd Gen) — callable | europe-west2 | Node.js 22, 512 MiB |
| createPayment | Cloud Functions (2nd Gen) — callable | europe-west2 | Node.js 22 |
| reconcileGiftCardBalances | Cloud Functions (1st Gen) — scheduled 24h | europe-west2 | Node.js 22 |
| Scheduled Functions | Cloud Functions (1st Gen) | europe-west2 | Node.js 22 |
| Firestore | Cloud Firestore | europe-west2 | Managed |
↳ giftCardLedger | Subcollection of merchants | — | Immutable, server-write |
↳ giftCardPayments | Subcollection of merchants | — | Immutable, server-write |
↳ reconciliationAlerts | Subcollection of merchants | — | Server-write, merchant-read |
↳ webhookEventsProcessed | Top-level collection | — | Dedup docs, TTL 30d |
↳ notificationLog | Subcollection of customerPasses | — | APNs rate-limit log |
↳ attributedVisits | Subcollection of merchants | — | 48h attribution window |
| Storage | Firebase Storage | Default bucket | Managed |
↳ exports/{merchantId}/ | CSV export files | — | Signed URLs, 1h expiry |
| Stripe Webhooks | Stripe to Cloud Functions | europe-west2 | HTTPS |
↳ charge.refunded | Stripe Connect webhook | — | Partial refund handler |
| onRetentionEventCreated | Cloud Functions (2nd Gen) — Firestore trigger | europe-west2 | Node.js 22 |
↳ retentionOrchestrator | Channel routing (wallet skip / email) | — | Hybrid lifecycle |
Canonical deployment targets and runtimes. Do not deploy docs to croacard-overview.web.app.
| Site | Domain | Contents |
|---|---|---|
| Main App | https://croacard.web.app | SPA + API rewrites |
| Overview Docs | https://overview.croacard.co.uk | C4 architecture static page |
| (Do not deploy) | https://croacard-overview.web.app | Legacy preview; keep disabled |
| Component | Platform | Region | Runtime |
|---|---|---|---|
| Web App | Firebase Hosting | Global CDN | Static (Vite build) |
| API + Callables + Triggers | Cloud Functions (2nd Gen) | europe-west2 | Node.js 22 |
| Scheduled Functions | Cloud Functions (1st Gen) | europe-west2 | Node.js 22 |
| Firestore | Cloud Firestore | europe-west2 | Managed |
| Storage | Firebase Storage | Default bucket | Managed |
| Stripe Webhooks | Stripe → Cloud Functions | europe-west2 | HTTPS |