Skip to content

Architecture

System Overview

Gavekort MultiTenant is a Firebase-based gift card and value wallet system for Golfklubb.

Stack

  • Backend: Cloud Functions (Node.js + TypeScript)
  • Database: Cloud Firestore
  • Authentication: Firebase Authentication + Custom Claims
  • Hosting: Firebase Hosting
  • IaC: Firestore rules + Functions code

Data Model

Core Collections

users

Stores user identity and roles.

{
  userId: string; // UUID
  externalAccountId?: string; // Club membership ID
  email: string;
  displayName: string;
  role: "END_USER" | "STAFF" | "ADMIN" | "AUDITOR";
  createdAt: Timestamp;
  updatedAt: Timestamp;
  isBlocked: boolean;
}

wallet_items

Stores giftcards, tokens, and tickets owned by users.

{
  walletItemId: string;
  userId: string;
  type: "GIFTCARD" | "RANGE_TOKEN" | "GREENFEE_TICKET";
  productId: string;
  value?: number; // For GIFTCARD (NOK) or RANGE_TOKEN (count)
  greenFeeType?: "9_HOLES" | "18_HOLES";
  quantity?: number;
  status: "ACTIVE" | "CLAIMED" | "REDEEMED" | "BLOCKED" | "EXPIRED";
  issuedAt: Timestamp;
  issuedBy: string; // Admin userId
  usedAmount?: number; // For partial redemptions
  expiresAt?: Timestamp;
  claimCodeHash?: string; // Reference to giftcard_codes
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

ledger_transactions (APPEND-ONLY)

The source of truth for all value movements. Never update directly.

{
  transactionId: string;
  userId: string;
  type: "ISSUE" | "CLAIM" | "REDEEM" | "REFUND" | "REVERSE" | "BLOCK" | "UNBLOCK" | "EXPIRE";
  walletItemId: string;
  amount?: number; // For REDEEM: how much was used
  idempotencyKey: string; // Reference to idempotency_keys/{keyId}
  actedBy: string; // Admin or user
  reason?: string;
  status: "PENDING" | "COMPLETED" | "FAILED";
  createdAt: Timestamp;
}

idempotency_keys

Atomic locks for preventing duplicate operations. Ensures exactly-once semantics.

{
  keyId: string; // The idempotency key from request
  operation: "ISSUE" | "REDEEM" | "CLAIM" | "REFUND" | "BLOCK";
  userId: string;
  status: "LOCKED" | "COMPLETED";
  ledgerTxId?: string; // Set when COMPLETED
  resultData?: object; // Response data for idempotent retry
  requestHash?: string; // SHA256 of request (detect tampering)
  createdAt: Timestamp;
  completedAt?: Timestamp;
  expiresAt: Timestamp; // TTL for cleanup (7 days default)
}

giftcard_codes

Mapping of code hashes → wallet items. For claim-based giftcards.

{
  codeHash: string; // SHA256(code + pepper)
  walletItemId?: string; // Set after claim
  status: "ACTIVE" | "CLAIMED" | "BLOCKED" | "EXPIRED";
  createdAt: Timestamp;
  claimedAt?: Timestamp;
  claimedByUserId?: string;
  expiresAt?: Timestamp;
}

wallet_projections/{userId}

Materialized view: aggregated balances. Rebuilt from ledger on every operation.

{
  userId: string;
  totalGiftcardBalance: number; // NOK
  totalRangeTokens: number;
  greenfeeTickets: { "9_HOLES": number; "18_HOLES": number };
  activeWalletItems: number;
  recentTransactions: LedgerTransaction[];
  lastUpdatedAt: Timestamp;
  lastUpdatedBy: string; // transactionId
  ledgerVersion: number; // Ledger entry count
}

redemptions

Record of each redemption event.

{
  redemptionId: string;
  walletItemId: string;
  userId: string;
  type: WalletItemType;
  amount: number; // NOK, count, or quantity
  redeemedAt: Timestamp;
  redeemedBy: string; // Admin or user
  ledgerTxId: string;
  location?: string;
  notes?: string;
}

admin_audit_log

Full audit trail of admin actions.

{
  auditId: string;
  adminUserId: string;
  action: "ISSUE" | "REFUND" | "REVERSE" | "BLOCK" | "UNBLOCK" | "SEARCH_USER" | "EXPORT_DATA";
  targetUserId?: string;
  targetWalletItemId?: string;
  details: object;
  reason?: string;
  success: boolean;
  error?: string;
  timestamp: Timestamp;
  ipAddress?: string;
}

products

Catalog of issuable items.

{
  productId: string;
  name: string;
  description?: string;
  type: WalletItemType;
  value?: number;
  credits?: number;
  greenFeeType?: GreenFeeType;
  expiryDays?: number;
  isClaimable: boolean;
  createdAt: Timestamp;
  updatedAt: Timestamp;
  isActive: boolean;
}

Key Principles

1. Append-Only Ledger

  • Truth: All balances are computed from ledger_transactions.
  • No direct updates: wallet_items.usedAmount is updated alongside ledger entries, but ledger is the source.
  • Audit: Every change is traceable.

2. Idempotency

  • Every operation requires an idempotencyKey in the request.
  • Lock is acquired atomically in idempotency_keys/{keyId}.
  • If same key is retried, previous result is returned.
  • Prevents duplicate charges on network failures.

3. Code Hashing

  • Giftcard codes are hashed with a pepper (secret) before storage.
  • Lookup: hash incoming code → query giftcard_codes/{codeHash}.
  • No plaintext codes in database.

4. RBAC via Custom Claims

  • Firebase Authentication + Custom Claims (role, permissions).
  • Roles: END_USER, STAFF, ADMIN, AUDITOR.
  • Enforced server-side in Firestore rules and Functions.

5. Wallet Projections

  • Denormalized view for quick reads (balance, recent transactions).
  • Rebuilt on every ledger update (via updateWalletProjection()).
  • Can be regenerated from ledger if needed.

Security Rules

See firestore.rules: - Users can read own data. - Admins can read all. - Only Functions (via service account) can write to ledger/projections. - Idempotency keys are internal only.


Indexes

Composite indexes required for efficient queries: - ledger_transactions: userId + status + createdAt - ledger_transactions: userId + type + createdAt - wallet_items: userId + status + expiresAt - wallet_items: userId + type - admin_audit_log: adminUserId + timestamp - admin_audit_log: action + timestamp

See FIRESTORE_INDEXES.md.


Functions

Issue Giftcard

issue_giftcard(data, context) → Issues a new giftcard to a user (with optional claim code).

Redeem Value

redeem_value(data, context) → Deduct amount from a wallet item.

Claim Code

claim_code(data, context) → Claim a giftcard using a code.

Scheduled Tasks

  • expire_wallet_items (daily 2 AM UTC): Mark expired items.
  • cleanup_idempotency_keys (daily 3 AM UTC): Delete old idempotency keys.

Next Steps

  1. Deploy Functions: npm run deploy (from functions/)
  2. Deploy Firestore Rules: firebase deploy --only firestore:rules
  3. Create Firestore Indexes (see FIRESTORE_INDEXES.md)
  4. Backend API (wrap Functions in REST/GraphQL layer)
  5. Frontend (User Wallet app)