System architecture diagrams plus native scanner view
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.
Camera control, scan processing, callable functions, and Apple/Google Wallet update path. Stamps require a transaction amount at scan time.
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)ScannerTab, 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 eventScannerTabcheckPassType (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 |