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.usedAmountis updated alongside ledger entries, but ledger is the source. - Audit: Every change is traceable.
2. Idempotency¶
- Every operation requires an
idempotencyKeyin 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¶
- Deploy Functions:
npm run deploy(fromfunctions/) - Deploy Firestore Rules:
firebase deploy --only firestore:rules - Create Firestore Indexes (see FIRESTORE_INDEXES.md)
- Backend API (wrap Functions in REST/GraphQL layer)
- Frontend (User Wallet app)