qualia-framework 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/bin/cli.js +519 -0
- package/framework/agents/architecture-strategist.md +53 -0
- package/framework/agents/backend-agent.md +150 -0
- package/framework/agents/code-simplicity-reviewer.md +86 -0
- package/framework/agents/frontend-agent.md +111 -0
- package/framework/agents/kieran-typescript-reviewer.md +96 -0
- package/framework/agents/performance-oracle.md +111 -0
- package/framework/agents/qualia-codebase-mapper.md +760 -0
- package/framework/agents/qualia-debugger.md +1203 -0
- package/framework/agents/qualia-executor.md +881 -0
- package/framework/agents/qualia-integration-checker.md +423 -0
- package/framework/agents/qualia-phase-researcher.md +453 -0
- package/framework/agents/qualia-plan-checker.md +699 -0
- package/framework/agents/qualia-planner.md +1241 -0
- package/framework/agents/qualia-project-researcher.md +602 -0
- package/framework/agents/qualia-research-synthesizer.md +236 -0
- package/framework/agents/qualia-roadmapper.md +605 -0
- package/framework/agents/qualia-verifier.md +685 -0
- package/framework/agents/team-orchestrator.md +228 -0
- package/framework/agents/teams/full-stack-team.md +48 -0
- package/framework/agents/teams/optimize-team.md +53 -0
- package/framework/agents/teams/review-team.md +62 -0
- package/framework/agents/teams/ship-team.md +86 -0
- package/framework/agents/test-agent.md +182 -0
- package/framework/askpass.sh +2 -0
- package/framework/commands/design.md +53 -0
- package/framework/commands/quick-db.md +22 -0
- package/framework/config/retention.json +35 -0
- package/framework/core/PRINCIPLES.md +77 -0
- package/framework/hooks/auto-format.sh +45 -0
- package/framework/hooks/block-env-edit.sh +42 -0
- package/framework/hooks/branch-guard.sh +46 -0
- package/framework/hooks/confirm-delete.sh +56 -0
- package/framework/hooks/migration-validate.sh +68 -0
- package/framework/hooks/notification-speak.sh +15 -0
- package/framework/hooks/pre-commit.sh +80 -0
- package/framework/hooks/pre-compact.sh +55 -0
- package/framework/hooks/pre-deploy-gate.sh +151 -0
- package/framework/hooks/qualia-colors.sh +32 -0
- package/framework/hooks/retention-cleanup.sh +43 -0
- package/framework/hooks/save-session-state.sh +153 -0
- package/framework/hooks/session-context-loader.sh +28 -0
- package/framework/hooks/session-learn.sh +30 -0
- package/framework/knowledge/claudecode-bible.md +1384 -0
- package/framework/knowledge/client-prefs.md +22 -0
- package/framework/knowledge/common-fixes.md +25 -0
- package/framework/knowledge/deployment-map.md +35 -0
- package/framework/knowledge/email-signature.html +1 -0
- package/framework/knowledge/employees.md +8 -0
- package/framework/knowledge/learned-patterns.md +51 -0
- package/framework/knowledge/optimization-research-2026.md +137 -0
- package/framework/knowledge/qualia-context.md +67 -0
- package/framework/knowledge/supabase-patterns.md +50 -0
- package/framework/knowledge/voice-agent-patterns.md +46 -0
- package/framework/qualia-engine/VERSION +1 -0
- package/framework/qualia-engine/bin/qualia-tools.js +2160 -0
- package/framework/qualia-engine/bin/qualia-tools.test.js +1054 -0
- package/framework/qualia-engine/references/checkpoints.md +775 -0
- package/framework/qualia-engine/references/continuation-format.md +249 -0
- package/framework/qualia-engine/references/decimal-phase-calculation.md +65 -0
- package/framework/qualia-engine/references/design-quality.md +56 -0
- package/framework/qualia-engine/references/git-integration.md +254 -0
- package/framework/qualia-engine/references/git-planning-commit.md +50 -0
- package/framework/qualia-engine/references/model-profile-resolution.md +32 -0
- package/framework/qualia-engine/references/model-profiles.md +73 -0
- package/framework/qualia-engine/references/phase-argument-parsing.md +61 -0
- package/framework/qualia-engine/references/planning-config.md +195 -0
- package/framework/qualia-engine/references/questioning.md +141 -0
- package/framework/qualia-engine/references/tdd.md +263 -0
- package/framework/qualia-engine/references/ui-brand.md +160 -0
- package/framework/qualia-engine/references/verification-patterns.md +612 -0
- package/framework/qualia-engine/templates/DEBUG.md +159 -0
- package/framework/qualia-engine/templates/DESIGN.md +81 -0
- package/framework/qualia-engine/templates/UAT.md +247 -0
- package/framework/qualia-engine/templates/codebase/architecture.md +255 -0
- package/framework/qualia-engine/templates/codebase/concerns.md +310 -0
- package/framework/qualia-engine/templates/codebase/conventions.md +307 -0
- package/framework/qualia-engine/templates/codebase/integrations.md +280 -0
- package/framework/qualia-engine/templates/codebase/stack.md +186 -0
- package/framework/qualia-engine/templates/codebase/structure.md +285 -0
- package/framework/qualia-engine/templates/codebase/testing.md +480 -0
- package/framework/qualia-engine/templates/config.json +35 -0
- package/framework/qualia-engine/templates/context.md +283 -0
- package/framework/qualia-engine/templates/continue-here.md +78 -0
- package/framework/qualia-engine/templates/debug-subagent-prompt.md +91 -0
- package/framework/qualia-engine/templates/discovery.md +146 -0
- package/framework/qualia-engine/templates/milestone-archive.md +123 -0
- package/framework/qualia-engine/templates/milestone.md +115 -0
- package/framework/qualia-engine/templates/phase-prompt.md +567 -0
- package/framework/qualia-engine/templates/planner-subagent-prompt.md +117 -0
- package/framework/qualia-engine/templates/project.md +184 -0
- package/framework/qualia-engine/templates/projects/ai-agent.md +156 -0
- package/framework/qualia-engine/templates/projects/mobile-app.md +181 -0
- package/framework/qualia-engine/templates/projects/voice-agent.md +134 -0
- package/framework/qualia-engine/templates/projects/website.md +137 -0
- package/framework/qualia-engine/templates/requirements.md +231 -0
- package/framework/qualia-engine/templates/research-project/ARCHITECTURE.md +204 -0
- package/framework/qualia-engine/templates/research-project/FEATURES.md +147 -0
- package/framework/qualia-engine/templates/research-project/PITFALLS.md +200 -0
- package/framework/qualia-engine/templates/research-project/STACK.md +120 -0
- package/framework/qualia-engine/templates/research-project/SUMMARY.md +170 -0
- package/framework/qualia-engine/templates/research.md +552 -0
- package/framework/qualia-engine/templates/roadmap.md +202 -0
- package/framework/qualia-engine/templates/state.md +176 -0
- package/framework/qualia-engine/templates/summary-complex.md +59 -0
- package/framework/qualia-engine/templates/summary-minimal.md +41 -0
- package/framework/qualia-engine/templates/summary-standard.md +48 -0
- package/framework/qualia-engine/templates/summary.md +246 -0
- package/framework/qualia-engine/templates/user-setup.md +311 -0
- package/framework/qualia-engine/templates/verification-report.md +322 -0
- package/framework/qualia-engine/workflows/add-phase.md +179 -0
- package/framework/qualia-engine/workflows/add-todo.md +157 -0
- package/framework/qualia-engine/workflows/audit-milestone.md +241 -0
- package/framework/qualia-engine/workflows/check-todos.md +176 -0
- package/framework/qualia-engine/workflows/complete-milestone.md +858 -0
- package/framework/qualia-engine/workflows/diagnose-issues.md +219 -0
- package/framework/qualia-engine/workflows/discovery-phase.md +289 -0
- package/framework/qualia-engine/workflows/discuss-phase.md +534 -0
- package/framework/qualia-engine/workflows/execute-phase.md +559 -0
- package/framework/qualia-engine/workflows/execute-plan.md +438 -0
- package/framework/qualia-engine/workflows/help.md +470 -0
- package/framework/qualia-engine/workflows/insert-phase.md +220 -0
- package/framework/qualia-engine/workflows/list-phase-assumptions.md +178 -0
- package/framework/qualia-engine/workflows/map-codebase.md +327 -0
- package/framework/qualia-engine/workflows/new-milestone.md +363 -0
- package/framework/qualia-engine/workflows/new-project.md +1037 -0
- package/framework/qualia-engine/workflows/pause-work.md +122 -0
- package/framework/qualia-engine/workflows/plan-milestone-gaps.md +256 -0
- package/framework/qualia-engine/workflows/plan-phase.md +422 -0
- package/framework/qualia-engine/workflows/progress.md +354 -0
- package/framework/qualia-engine/workflows/quick.md +252 -0
- package/framework/qualia-engine/workflows/remove-phase.md +326 -0
- package/framework/qualia-engine/workflows/research-phase.md +74 -0
- package/framework/qualia-engine/workflows/resume-project.md +306 -0
- package/framework/qualia-engine/workflows/set-profile.md +80 -0
- package/framework/qualia-engine/workflows/settings.md +145 -0
- package/framework/qualia-engine/workflows/transition.md +556 -0
- package/framework/qualia-engine/workflows/update.md +197 -0
- package/framework/qualia-engine/workflows/verify-phase.md +195 -0
- package/framework/qualia-engine/workflows/verify-work.md +625 -0
- package/framework/rules/context7.md +11 -0
- package/framework/rules/deployment.md +29 -0
- package/framework/rules/frontend.md +33 -0
- package/framework/rules/security.md +12 -0
- package/framework/rules/speed.md +20 -0
- package/framework/scripts/__pycache__/say.cpython-314.pyc +0 -0
- package/framework/scripts/apply-retention.sh +120 -0
- package/framework/scripts/bootstrap-pop-os.sh +354 -0
- package/framework/scripts/claude-voice +13 -0
- package/framework/scripts/cleanup.sh +131 -0
- package/framework/scripts/cowork-mode.sh +141 -0
- package/framework/scripts/generate-project-claude-md.sh +153 -0
- package/framework/scripts/load-test-webhook.js +172 -0
- package/framework/scripts/say.py +236 -0
- package/framework/scripts/showcase-video-recorder/ffmpeg-builder.js +167 -0
- package/framework/scripts/showcase-video-recorder/playwright-helpers.js +216 -0
- package/framework/scripts/speak.py +55 -0
- package/framework/scripts/speak.sh +18 -0
- package/framework/scripts/status.sh +138 -0
- package/framework/scripts/sync-to-framework.sh +65 -0
- package/framework/scripts/voice-hotkey.py +227 -0
- package/framework/scripts/voice-input.sh +51 -0
- package/framework/skills/animate/SKILL.md +202 -0
- package/framework/skills/bolder/SKILL.md +144 -0
- package/framework/skills/browser-qa/SKILL.md +536 -0
- package/framework/skills/clarify/SKILL.md +179 -0
- package/framework/skills/colorize/SKILL.md +170 -0
- package/framework/skills/critique/SKILL.md +126 -0
- package/framework/skills/deep-research/SKILL.md +271 -0
- package/framework/skills/delight/SKILL.md +329 -0
- package/framework/skills/deploy/SKILL.md +261 -0
- package/framework/skills/deploy-verify/SKILL.md +377 -0
- package/framework/skills/deploy-verify/scripts/canary-check.sh +206 -0
- package/framework/skills/deploy-verify/scripts/check-console-errors.js +147 -0
- package/framework/skills/deploy-verify/scripts/check-cwv.js +139 -0
- package/framework/skills/deploy-verify/scripts/project-detect.sh +84 -0
- package/framework/skills/deploy-verify/scripts/verify.sh +548 -0
- package/framework/skills/design-quieter/SKILL.md +130 -0
- package/framework/skills/distill/SKILL.md +149 -0
- package/framework/skills/docs-lookup/SKILL.md +78 -0
- package/framework/skills/fcm-notifications/SKILL.md +125 -0
- package/framework/skills/financial-ledger/SKILL.md +1039 -0
- package/framework/skills/frontend-master/NOTICE.md +4 -0
- package/framework/skills/frontend-master/SKILL.md +127 -0
- package/framework/skills/frontend-master/reference/color-and-contrast.md +132 -0
- package/framework/skills/frontend-master/reference/interaction-design.md +123 -0
- package/framework/skills/frontend-master/reference/motion-design.md +99 -0
- package/framework/skills/frontend-master/reference/responsive-design.md +114 -0
- package/framework/skills/frontend-master/reference/spatial-design.md +100 -0
- package/framework/skills/frontend-master/reference/typography.md +131 -0
- package/framework/skills/frontend-master/reference/ux-writing.md +107 -0
- package/framework/skills/harden/SKILL.md +357 -0
- package/framework/skills/i18n-rtl/SKILL.md +752 -0
- package/framework/skills/learn/SKILL.md +71 -0
- package/framework/skills/memory/SKILL.md +50 -0
- package/framework/skills/mobile-expo/SKILL.md +864 -0
- package/framework/skills/mobile-expo/references/store-checklist.md +550 -0
- package/framework/skills/nestjs-backend/README.md +73 -0
- package/framework/skills/nestjs-backend/SKILL.md +446 -0
- package/framework/skills/nestjs-backend/references/templates.md +1173 -0
- package/framework/skills/normalize/SKILL.md +79 -0
- package/framework/skills/onboard/SKILL.md +242 -0
- package/framework/skills/polish/SKILL.md +209 -0
- package/framework/skills/pr/SKILL.md +66 -0
- package/framework/skills/qualia/SKILL.md +153 -0
- package/framework/skills/qualia-add-todo/SKILL.md +68 -0
- package/framework/skills/qualia-audit-milestone/SKILL.md +92 -0
- package/framework/skills/qualia-check-todos/SKILL.md +55 -0
- package/framework/skills/qualia-complete-milestone/SKILL.md +108 -0
- package/framework/skills/qualia-debug/SKILL.md +149 -0
- package/framework/skills/qualia-design/SKILL.md +203 -0
- package/framework/skills/qualia-discuss-phase/SKILL.md +72 -0
- package/framework/skills/qualia-execute-phase/SKILL.md +86 -0
- package/framework/skills/qualia-help/SKILL.md +67 -0
- package/framework/skills/qualia-idk/SKILL.md +352 -0
- package/framework/skills/qualia-list-phase-assumptions/SKILL.md +67 -0
- package/framework/skills/qualia-new-milestone/SKILL.md +72 -0
- package/framework/skills/qualia-new-project/SKILL.md +92 -0
- package/framework/skills/qualia-optimize/SKILL.md +417 -0
- package/framework/skills/qualia-pause-work/SKILL.md +96 -0
- package/framework/skills/qualia-plan-milestone-gaps/SKILL.md +57 -0
- package/framework/skills/qualia-plan-phase/SKILL.md +101 -0
- package/framework/skills/qualia-progress/SKILL.md +53 -0
- package/framework/skills/qualia-quick/SKILL.md +89 -0
- package/framework/skills/qualia-research-phase/SKILL.md +88 -0
- package/framework/skills/qualia-resume-work/SKILL.md +62 -0
- package/framework/skills/qualia-review/SKILL.md +263 -0
- package/framework/skills/qualia-start/SKILL.md +182 -0
- package/framework/skills/qualia-verify-work/SKILL.md +105 -0
- package/framework/skills/qualia-workflow/SKILL.md +130 -0
- package/framework/skills/rag/SKILL.md +750 -0
- package/framework/skills/responsive/SKILL.md +231 -0
- package/framework/skills/retro/SKILL.md +284 -0
- package/framework/skills/sakani-conventions/SKILL.md +136 -0
- package/framework/skills/sakani-conventions/evals/evals.json +23 -0
- package/framework/skills/sakani-conventions/references/entities.md +365 -0
- package/framework/skills/sakani-conventions/references/error-codes.md +95 -0
- package/framework/skills/seo-master/SKILL.md +490 -0
- package/framework/skills/seo-master/references/checklist.md +199 -0
- package/framework/skills/seo-master/references/structured-data.md +609 -0
- package/framework/skills/ship/SKILL.md +202 -0
- package/framework/skills/stack-researcher/SKILL.md +215 -0
- package/framework/skills/status/SKILL.md +154 -0
- package/framework/skills/status/scripts/health-check.sh +562 -0
- package/framework/skills/subscription-payments/SKILL.md +250 -0
- package/framework/skills/supabase/SKILL.md +973 -0
- package/framework/skills/supabase/references/templates.md +159 -0
- package/framework/skills/team/SKILL.md +67 -0
- package/framework/skills/test-runner/SKILL.md +202 -0
- package/framework/skills/voice-agent/SKILL.md +407 -0
- package/framework/skills/zoho-workflow/SKILL.md +51 -0
- package/framework/statusline-command.sh +117 -0
- package/package.json +24 -0
- package/profiles/fawzi.json +16 -0
- package/profiles/hasan.json +16 -0
- package/profiles/moayad.json +16 -0
- package/templates/CLAUDE-owner.md +52 -0
- package/templates/CLAUDE.md.hbs +58 -0
- package/templates/env.claude.template +12 -0
- package/templates/settings.json +141 -0
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: financial-ledger
|
|
3
|
+
description: "Financial ledger and accounting patterns — append-only ledgers, idempotent financial writes, FIFO credit settlement, auto-settlement jobs, statement generation, audit trails, and monetary value handling. Use whenever implementing financial tracking, payment recording, dues/billing systems, credit management, settlement logic, or any code that touches money. This is critical for correctness — financial code has zero tolerance for bugs. Triggers on: ledger, financial, dues, billing, settlement, credit, payment recording, monetary, accounting, audit trail, statement, invoice, FIFO, idempotency."
|
|
4
|
+
tags: [finance, ledger, accounting, payments]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Financial Ledger Patterns
|
|
8
|
+
|
|
9
|
+
This skill covers production-grade financial ledger and accounting patterns. The patterns here are specialized for property management but the core principles (append-only ledger, idempotency, FIFO settlement) are universally reusable across any fintech application.
|
|
10
|
+
|
|
11
|
+
## Core Principle: Append-Only Ledger
|
|
12
|
+
|
|
13
|
+
The ledger is **never modified after creation**. No UPDATE, no DELETE. Period.
|
|
14
|
+
|
|
15
|
+
This is the foundational principle of financial accounting. Banks use it. Accounting standards mandate it. Your ledger must too.
|
|
16
|
+
|
|
17
|
+
- **Corrections use REVERSAL entries** that negate the original, creating a clear audit trail
|
|
18
|
+
- **Every entry has**:
|
|
19
|
+
- `timestamp` — when the entry was created
|
|
20
|
+
- `actor_user_id` — who created it
|
|
21
|
+
- `request_id` — idempotency key to prevent duplicates
|
|
22
|
+
- `status` — PENDING, COMPLETED, FAILED (for async operations)
|
|
23
|
+
- **Queries construct current state** by summing entries, never by querying a "current balance" field
|
|
24
|
+
- **Immutability is guaranteed** by database constraints and application logic
|
|
25
|
+
|
|
26
|
+
### Why Append-Only?
|
|
27
|
+
|
|
28
|
+
1. **Audit trail is automatic** — no need to reconstruct "what happened"
|
|
29
|
+
2. **Regulatory compliance** — many jurisdictions require immutable financial records
|
|
30
|
+
3. **Bug safety** — if you accidentally record the wrong amount, you can reverse it with full visibility
|
|
31
|
+
4. **Debugging** — "why is the balance wrong?" becomes a simple query of ledger entries
|
|
32
|
+
5. **Concurrency safety** — append operations are naturally conflict-free
|
|
33
|
+
|
|
34
|
+
## Ledger Entry Types (Property Management Example)
|
|
35
|
+
|
|
36
|
+
Define entry types for your domain. This example uses property management, but adapt the types to your context:
|
|
37
|
+
|
|
38
|
+
| Type | Who Creates | Validation | Side Effects |
|
|
39
|
+
|------|-------------|-----------|---------------|
|
|
40
|
+
| `OPENING_BALANCE` | System/Admin | One per unit, only at account creation | Sets initial balance, immutable |
|
|
41
|
+
| `CREDIT_TOPUP` | Resident/Admin | Amount > 0, linked to payment method | Increases unit credit balance |
|
|
42
|
+
| `MANUAL_UNIT_INCOME` | Building Manager | Amount > 0, requires description | Income source recorded for audit |
|
|
43
|
+
| `EXPENSE` | Building Manager | Amount > 0, category, description | Building operating cost, splits across units |
|
|
44
|
+
| `SUBSCRIPTION_FEE` | System | Fixed amount, per building, monthly | Platform revenue, creates obligation |
|
|
45
|
+
| `AUTOSETTLEMENT_APPLIED` | Settlement job | System-generated, never manual | Links credit to obligations, triggers FIFO logic |
|
|
46
|
+
| `REVERSAL` | Admin (rare) | References original entry_id, same amount | Negates previous entry, visible in audit |
|
|
47
|
+
|
|
48
|
+
### Entry Validation Rules
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
interface LedgerEntry {
|
|
52
|
+
id: string;
|
|
53
|
+
unit_id: string;
|
|
54
|
+
building_id: string;
|
|
55
|
+
type: 'OPENING_BALANCE' | 'CREDIT_TOPUP' | 'MANUAL_UNIT_INCOME' | 'EXPENSE' | 'SUBSCRIPTION_FEE' | 'AUTOSETTLEMENT_APPLIED' | 'REVERSAL';
|
|
56
|
+
amount: number; // Integer cents in production
|
|
57
|
+
currency: string; // ISO 4217 (e.g., 'JOD')
|
|
58
|
+
description: string;
|
|
59
|
+
actor_user_id: string;
|
|
60
|
+
created_at: string; // ISO timestamp
|
|
61
|
+
request_id: string; // Idempotency key (UUID v4)
|
|
62
|
+
status: 'PENDING' | 'COMPLETED' | 'FAILED';
|
|
63
|
+
metadata: Record<string, any>; // Type-specific details
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Validation per type:**
|
|
68
|
+
- `OPENING_BALANCE`: Exactly one per unit in entire history
|
|
69
|
+
- `CREDIT_TOPUP`: amount > 0, currency matches building
|
|
70
|
+
- `MANUAL_UNIT_INCOME`: amount > 0, description required, BM authorization
|
|
71
|
+
- `EXPENSE`: amount > 0, category in valid list, affects shared obligation pool
|
|
72
|
+
- `SUBSCRIPTION_FEE`: amount matches subscription plan, deterministic billing date
|
|
73
|
+
- `AUTOSETTLEMENT_APPLIED`: system-generated only, amount <= available credit
|
|
74
|
+
- `REVERSAL`: references valid entry_id, same amount and currency, created within 30 days of original
|
|
75
|
+
|
|
76
|
+
## Monetary Values
|
|
77
|
+
|
|
78
|
+
Never, ever, store money as a floating-point number. This is non-negotiable.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
interface Money {
|
|
82
|
+
amount: number; // Integer cents in production (e.g., 1000 = $10.00 or 1000 fils)
|
|
83
|
+
currency: string; // ISO 4217 code (e.g., 'USD', 'JOD', 'EUR')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// DO NOT:
|
|
87
|
+
const balance = 100.50; // ❌ Floating point is imprecise
|
|
88
|
+
const balance = 100; // ❌ Missing currency
|
|
89
|
+
|
|
90
|
+
// DO:
|
|
91
|
+
const balance: Money = { amount: 10050, currency: 'USD' }; // ✓ Integer cents
|
|
92
|
+
const balance: Money = { amount: 100, currency: 'JOD' }; // ✓ Fils (smallest unit)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Money Handling Rules
|
|
96
|
+
|
|
97
|
+
1. **Store as integer cents** (or smallest currency unit)
|
|
98
|
+
- 1 USD = 100 cents
|
|
99
|
+
- 1 JOD = 1000 fils
|
|
100
|
+
- Always use the smallest unit to avoid floating-point errors
|
|
101
|
+
|
|
102
|
+
2. **Always include currency alongside amount**
|
|
103
|
+
- You cannot compare JOD with USD without exchange rates
|
|
104
|
+
- Store and query together: `WHERE amount > 0 AND currency = 'JOD'`
|
|
105
|
+
|
|
106
|
+
3. **Never compare money with `===`**
|
|
107
|
+
```typescript
|
|
108
|
+
// ❌ Wrong:
|
|
109
|
+
if (balance === 10050) { }
|
|
110
|
+
|
|
111
|
+
// ✓ Correct:
|
|
112
|
+
function moneyEqual(a: Money, b: Money): boolean {
|
|
113
|
+
return a.amount === b.amount && a.currency === b.currency;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
4. **Round only at display boundaries**
|
|
118
|
+
- All calculations use integers (cents/fils)
|
|
119
|
+
- Only format to decimals when printing or displaying to users
|
|
120
|
+
- Never round mid-calculation
|
|
121
|
+
|
|
122
|
+
5. **Use Decimal library for complex calculations** (if needed)
|
|
123
|
+
```typescript
|
|
124
|
+
import Decimal from 'decimal.js';
|
|
125
|
+
|
|
126
|
+
const tax = new Decimal('100').times('0.16'); // ✓ Precise
|
|
127
|
+
const cents = tax.toNumber(); // Convert to integer cents only at boundary
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
6. **Validate on input**
|
|
131
|
+
```typescript
|
|
132
|
+
function validateMoney(m: Money): boolean {
|
|
133
|
+
// amount must be integer
|
|
134
|
+
if (!Number.isInteger(m.amount)) return false;
|
|
135
|
+
// amount must be >= 0 for most operations
|
|
136
|
+
if (m.amount < 0) return false;
|
|
137
|
+
// currency must be valid ISO code
|
|
138
|
+
if (!isValidCurrency(m.currency)) return false;
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Idempotency Pattern
|
|
144
|
+
|
|
145
|
+
Every financial write **must be idempotent**. This prevents double-charges, double-credits, and double-settlements.
|
|
146
|
+
|
|
147
|
+
### The Pattern
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
interface CreateLedgerEntryDto {
|
|
151
|
+
unitId: string;
|
|
152
|
+
type: string;
|
|
153
|
+
amount: number;
|
|
154
|
+
currency: string;
|
|
155
|
+
description: string;
|
|
156
|
+
idempotencyKey: string; // UUID v4, generated by client
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async createLedgerEntry(entry: CreateLedgerEntryDto) {
|
|
160
|
+
// Step 1: Check for existing entry with same idempotency key
|
|
161
|
+
const existing = await this.supabase
|
|
162
|
+
.from('ledger_entry')
|
|
163
|
+
.select('*')
|
|
164
|
+
.eq('request_id', entry.idempotencyKey)
|
|
165
|
+
.eq('unit_id', entry.unitId)
|
|
166
|
+
.single();
|
|
167
|
+
|
|
168
|
+
if (existing.data) {
|
|
169
|
+
// Return the original — do not create duplicate
|
|
170
|
+
return { data: existing.data, isDuplicate: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Step 2: Create new entry with idempotency key stored
|
|
174
|
+
const created = await this.supabase
|
|
175
|
+
.from('ledger_entry')
|
|
176
|
+
.insert({
|
|
177
|
+
unit_id: entry.unitId,
|
|
178
|
+
type: entry.type,
|
|
179
|
+
amount: entry.amount,
|
|
180
|
+
currency: entry.currency,
|
|
181
|
+
description: entry.description,
|
|
182
|
+
request_id: entry.idempotencyKey,
|
|
183
|
+
actor_user_id: this.currentUser.id,
|
|
184
|
+
created_at: new Date().toISOString(),
|
|
185
|
+
status: 'COMPLETED',
|
|
186
|
+
})
|
|
187
|
+
.select()
|
|
188
|
+
.single();
|
|
189
|
+
|
|
190
|
+
// Step 3: Return created entry
|
|
191
|
+
return { data: created.data, isDuplicate: false };
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Idempotency Key Rules
|
|
196
|
+
|
|
197
|
+
- **Client generates it** (UUID v4)
|
|
198
|
+
- **Server stores it** as `request_id` with UNIQUE constraint
|
|
199
|
+
- **Duplicate request** → return original result (HTTP 200, not 409)
|
|
200
|
+
- **Retry-safe** — network timeout? Retry safely without side effects
|
|
201
|
+
- **Long retention** — keep for at least 24 hours (longer for critical operations like settlements)
|
|
202
|
+
|
|
203
|
+
### Client Usage
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
async function topupCredit(unitId: string, amount: number, currency: string) {
|
|
207
|
+
const idempotencyKey = uuidv4(); // Generate once, reuse on retry
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const response = await api.post('/ledger/create', {
|
|
211
|
+
unitId,
|
|
212
|
+
type: 'CREDIT_TOPUP',
|
|
213
|
+
amount,
|
|
214
|
+
currency,
|
|
215
|
+
idempotencyKey,
|
|
216
|
+
}, {
|
|
217
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
218
|
+
});
|
|
219
|
+
return response.data;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (isNetworkError(error)) {
|
|
222
|
+
// Safe to retry with same idempotencyKey
|
|
223
|
+
return topupCredit(unitId, amount, currency);
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## FIFO Credit Settlement
|
|
231
|
+
|
|
232
|
+
When a unit has available credit and unpaid obligations, apply credit using **First-In-First-Out (FIFO)** logic.
|
|
233
|
+
|
|
234
|
+
### The Algorithm
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
async settleObligations(
|
|
238
|
+
unitId: string,
|
|
239
|
+
availableCredit: number,
|
|
240
|
+
currency: string
|
|
241
|
+
): Promise<Settlement[]> {
|
|
242
|
+
// Step 1: Get unpaid obligations, sorted by due date (oldest first)
|
|
243
|
+
const obligations = await this.supabase
|
|
244
|
+
.from('dues_obligation')
|
|
245
|
+
.select('*')
|
|
246
|
+
.eq('unit_id', unitId)
|
|
247
|
+
.eq('status', 'UNPAID')
|
|
248
|
+
.order('due_date', { ascending: true });
|
|
249
|
+
|
|
250
|
+
let remainingCredit = availableCredit;
|
|
251
|
+
const settlements: Settlement[] = [];
|
|
252
|
+
|
|
253
|
+
// Step 2: For each obligation (oldest first), apply credit
|
|
254
|
+
for (const obligation of obligations.data) {
|
|
255
|
+
// All-or-nothing: settle fully or skip entirely
|
|
256
|
+
if (remainingCredit >= obligation.amount) {
|
|
257
|
+
remainingCredit -= obligation.amount;
|
|
258
|
+
settlements.push({
|
|
259
|
+
obligationId: obligation.id,
|
|
260
|
+
amount: obligation.amount,
|
|
261
|
+
currency,
|
|
262
|
+
settledAt: new Date().toISOString(),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// If credit < obligation amount, skip (no partial settlement)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Step 3: Execute all settlements atomically (single transaction)
|
|
269
|
+
return await this.executeSettlements(unitId, settlements);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async executeSettlements(
|
|
273
|
+
unitId: string,
|
|
274
|
+
settlements: Settlement[]
|
|
275
|
+
): Promise<Settlement[]> {
|
|
276
|
+
// Wrap in transaction to ensure atomicity
|
|
277
|
+
const { data, error } = await this.supabase.rpc('settle_obligations', {
|
|
278
|
+
unit_id: unitId,
|
|
279
|
+
settlements_json: JSON.stringify(settlements),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (error) throw error;
|
|
283
|
+
|
|
284
|
+
// Create ledger entries for each settlement
|
|
285
|
+
for (const settlement of settlements) {
|
|
286
|
+
await this.createLedgerEntry({
|
|
287
|
+
unitId,
|
|
288
|
+
type: 'AUTOSETTLEMENT_APPLIED',
|
|
289
|
+
amount: settlement.amount,
|
|
290
|
+
currency: settlement.currency,
|
|
291
|
+
description: `Settled obligation ${settlement.obligationId}`,
|
|
292
|
+
idempotencyKey: `settlement-${settlement.obligationId}-${new Date().toISOString()}`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return settlements;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### FIFO Rules
|
|
301
|
+
|
|
302
|
+
1. **Sort by due_date ASC** — oldest obligations first
|
|
303
|
+
2. **All-or-nothing** — settle obligation fully or not at all
|
|
304
|
+
3. **No partial settlement** — if credit < obligation amount, skip that obligation entirely
|
|
305
|
+
4. **Atomic execution** — all settlements succeed or all fail (no partial state)
|
|
306
|
+
5. **One pass** — process each obligation once per settlement run
|
|
307
|
+
|
|
308
|
+
### Why FIFO?
|
|
309
|
+
|
|
310
|
+
- **Fair** — obligations are settled in order they became due
|
|
311
|
+
- **Predictable** — behavior is deterministic and auditable
|
|
312
|
+
- **Legal** — matches accounting standards (payment application rules)
|
|
313
|
+
- **Simple** — no complex allocation logic
|
|
314
|
+
|
|
315
|
+
## Auto-Settlement Job
|
|
316
|
+
|
|
317
|
+
Run settlement logic automatically on a schedule.
|
|
318
|
+
|
|
319
|
+
### Job Specification
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
interface SettlementJob {
|
|
323
|
+
jobId: string;
|
|
324
|
+
scheduledAt: string; // ISO timestamp
|
|
325
|
+
completedAt?: string;
|
|
326
|
+
status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED';
|
|
327
|
+
buildingId: string;
|
|
328
|
+
unitsProcessed: number;
|
|
329
|
+
settlementsApplied: number;
|
|
330
|
+
errors: Array<{ unitId: string; error: string }>;
|
|
331
|
+
idempotencyKey: string; // Prevent duplicate runs
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Implementation Pattern
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
async function runAutoSettlementJob(jobIdempotencyKey: string) {
|
|
339
|
+
// Check if job already ran (idempotency)
|
|
340
|
+
const existing = await db.settlementJob.findUnique({
|
|
341
|
+
where: { idempotencyKey: jobIdempotencyKey },
|
|
342
|
+
});
|
|
343
|
+
if (existing) return existing;
|
|
344
|
+
|
|
345
|
+
// Create job record
|
|
346
|
+
const job = await db.settlementJob.create({
|
|
347
|
+
data: {
|
|
348
|
+
idempotencyKey: jobIdempotencyKey,
|
|
349
|
+
status: 'RUNNING',
|
|
350
|
+
scheduledAt: new Date(),
|
|
351
|
+
unitsProcessed: 0,
|
|
352
|
+
settlementsApplied: 0,
|
|
353
|
+
errors: [],
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Get all buildings with active subscription
|
|
358
|
+
const buildings = await db.building.findMany({
|
|
359
|
+
where: { subscriptionStatus: 'ACTIVE' },
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
let settled = 0;
|
|
363
|
+
const errors = [];
|
|
364
|
+
|
|
365
|
+
for (const building of buildings) {
|
|
366
|
+
try {
|
|
367
|
+
// Get all units in building with available credit
|
|
368
|
+
const units = await db.unit.findMany({
|
|
369
|
+
where: { buildingId: building.id },
|
|
370
|
+
include: { creditBalance: true },
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
for (const unit of units) {
|
|
374
|
+
const credit = unit.creditBalance.amount;
|
|
375
|
+
if (credit > 0) {
|
|
376
|
+
const settlements = await settleObligations(
|
|
377
|
+
unit.id,
|
|
378
|
+
credit,
|
|
379
|
+
building.currency
|
|
380
|
+
);
|
|
381
|
+
settled += settlements.length;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} catch (error) {
|
|
385
|
+
errors.push({
|
|
386
|
+
buildingId: building.id,
|
|
387
|
+
error: error.message,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Update job record
|
|
393
|
+
return await db.settlementJob.update({
|
|
394
|
+
where: { id: job.id },
|
|
395
|
+
data: {
|
|
396
|
+
status: 'COMPLETED',
|
|
397
|
+
completedAt: new Date(),
|
|
398
|
+
unitsProcessed: buildings.length,
|
|
399
|
+
settlementsApplied: settled,
|
|
400
|
+
errors,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Schedule Configuration
|
|
407
|
+
|
|
408
|
+
- **Frequency**: Daily
|
|
409
|
+
- **Time**: 07:00 Asia/Amman (UTC+3)
|
|
410
|
+
- **Idempotency**: Use date as key (`settlement-${YYYY-MM-DD}`) to prevent duplicate runs
|
|
411
|
+
- **Observability**: Log every settlement, track job status, alert on failures
|
|
412
|
+
- **Timeout**: 5 minutes max (prevent long-running processes)
|
|
413
|
+
|
|
414
|
+
### Cron Expression
|
|
415
|
+
|
|
416
|
+
```
|
|
417
|
+
0 7 * * * # Daily at 07:00 (UTC+3)
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Implementation (Supabase Edge Function)
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
// Run as Supabase scheduled function
|
|
424
|
+
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts';
|
|
425
|
+
|
|
426
|
+
serve(async (req) => {
|
|
427
|
+
const today = new Date().toISOString().split('T')[0];
|
|
428
|
+
const idempotencyKey = `settlement-${today}`;
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const result = await runAutoSettlementJob(idempotencyKey);
|
|
432
|
+
return new Response(JSON.stringify(result), { status: 200 });
|
|
433
|
+
} catch (error) {
|
|
434
|
+
console.error('Settlement job failed:', error);
|
|
435
|
+
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Late Payment Tracking
|
|
441
|
+
|
|
442
|
+
Track when obligations become late.
|
|
443
|
+
|
|
444
|
+
### Status Transitions
|
|
445
|
+
|
|
446
|
+
- **UNPAID** → **LATE**: Transition occurs at **cycle boundary** (when new dues cycle starts)
|
|
447
|
+
- **LATE** → **SETTLED**: When credit is applied via FIFO settlement
|
|
448
|
+
- **UNPAID** → **SETTLED**: Direct settlement (no LATE status)
|
|
449
|
+
|
|
450
|
+
### Cycle Boundary Logic
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
async function markLateObligations() {
|
|
454
|
+
// Get the current billing cycle
|
|
455
|
+
const cycle = await db.billingCycle.findFirst({
|
|
456
|
+
where: { status: 'ACTIVE' },
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Mark obligations from previous cycles as LATE
|
|
460
|
+
await db.obligation.updateMany({
|
|
461
|
+
where: {
|
|
462
|
+
dueCycleId: { not: cycle.id },
|
|
463
|
+
status: 'UNPAID',
|
|
464
|
+
},
|
|
465
|
+
data: { status: 'LATE' },
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Batch Job Schedule
|
|
471
|
+
|
|
472
|
+
- Runs at cycle boundary (e.g., 1st of each month at 01:00)
|
|
473
|
+
- Idempotent: safe to re-run
|
|
474
|
+
- Creates audit log entries for each status change
|
|
475
|
+
|
|
476
|
+
### Queries
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// Get unpaid obligations (includes LATE)
|
|
480
|
+
const unpaid = await db.obligation.findMany({
|
|
481
|
+
where: { status: { in: ['UNPAID', 'LATE'] } },
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Get only LATE obligations
|
|
485
|
+
const late = await db.obligation.findMany({
|
|
486
|
+
where: { status: 'LATE' },
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
## Statement Generation
|
|
491
|
+
|
|
492
|
+
Generate monthly financial statements per building.
|
|
493
|
+
|
|
494
|
+
### Statement Structure
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
interface BuildingStatement {
|
|
498
|
+
statementId: string;
|
|
499
|
+
buildingId: string;
|
|
500
|
+
month: string; // YYYY-MM
|
|
501
|
+
generatedAt: string;
|
|
502
|
+
version: number;
|
|
503
|
+
versionToken: string; // For cache invalidation
|
|
504
|
+
|
|
505
|
+
// Summary
|
|
506
|
+
openingBalance: Money;
|
|
507
|
+
closingBalance: Money;
|
|
508
|
+
totalIncome: Money;
|
|
509
|
+
totalExpenses: Money;
|
|
510
|
+
|
|
511
|
+
// Details
|
|
512
|
+
ledgerEntries: LedgerEntry[];
|
|
513
|
+
settlementDetails: Settlement[];
|
|
514
|
+
outstandingObligations: Obligation[];
|
|
515
|
+
|
|
516
|
+
// Audit
|
|
517
|
+
hash: string; // SHA-256 of statement content
|
|
518
|
+
signedAt?: string;
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Generation Process
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
async function generateStatement(
|
|
526
|
+
buildingId: string,
|
|
527
|
+
month: string // 'YYYY-MM'
|
|
528
|
+
): Promise<BuildingStatement> {
|
|
529
|
+
// Check cache first
|
|
530
|
+
const cached = await cache.get(`statement-${buildingId}-${month}`);
|
|
531
|
+
if (cached) return cached;
|
|
532
|
+
|
|
533
|
+
// Fetch ledger entries for the month
|
|
534
|
+
const [startDate, endDate] = getMonthRange(month);
|
|
535
|
+
const ledgerEntries = await db.ledgerEntry.findMany({
|
|
536
|
+
where: {
|
|
537
|
+
buildingId,
|
|
538
|
+
createdAt: { gte: startDate, lte: endDate },
|
|
539
|
+
},
|
|
540
|
+
orderBy: { createdAt: 'asc' },
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Calculate opening balance (end of previous month)
|
|
544
|
+
const openingBalance = await getClosingBalance(
|
|
545
|
+
buildingId,
|
|
546
|
+
new Date(startDate.getTime() - 1000)
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
// Calculate closing balance (end of current month)
|
|
550
|
+
const closingBalance = await getClosingBalance(buildingId, endDate);
|
|
551
|
+
|
|
552
|
+
// Build statement
|
|
553
|
+
const statement: BuildingStatement = {
|
|
554
|
+
statementId: uuidv4(),
|
|
555
|
+
buildingId,
|
|
556
|
+
month,
|
|
557
|
+
generatedAt: new Date().toISOString(),
|
|
558
|
+
version: 1,
|
|
559
|
+
versionToken: uuidv4(),
|
|
560
|
+
openingBalance,
|
|
561
|
+
closingBalance,
|
|
562
|
+
totalIncome: sumByType(ledgerEntries, ['CREDIT_TOPUP', 'MANUAL_UNIT_INCOME']),
|
|
563
|
+
totalExpenses: sumByType(ledgerEntries, ['EXPENSE', 'SUBSCRIPTION_FEE']),
|
|
564
|
+
ledgerEntries,
|
|
565
|
+
settlementDetails: await getSettlementsForPeriod(buildingId, startDate, endDate),
|
|
566
|
+
outstandingObligations: await getOutstandingAt(buildingId, endDate),
|
|
567
|
+
hash: hashStatement(statement),
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// Cache for 7 days
|
|
571
|
+
await cache.set(`statement-${buildingId}-${month}`, statement, 7 * 24 * 60 * 60);
|
|
572
|
+
|
|
573
|
+
return statement;
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Versioning
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
interface StatementVersion {
|
|
581
|
+
statementId: string;
|
|
582
|
+
version: number;
|
|
583
|
+
versionToken: string; // UUID for cache busting
|
|
584
|
+
hash: string; // Immutability check
|
|
585
|
+
createdAt: string;
|
|
586
|
+
pdfUrl?: string; // Pre-generated PDF
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Generate new version if content changes
|
|
590
|
+
async function getOrGenerateStatementPDF(
|
|
591
|
+
buildingId: string,
|
|
592
|
+
month: string
|
|
593
|
+
): Promise<{ url: string; version: number }> {
|
|
594
|
+
const statement = await generateStatement(buildingId, month);
|
|
595
|
+
|
|
596
|
+
// Check if PDF already generated for this version
|
|
597
|
+
let pdfVersion = await db.statementPdf.findFirst({
|
|
598
|
+
where: {
|
|
599
|
+
statementId: statement.statementId,
|
|
600
|
+
version: statement.version,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
if (!pdfVersion) {
|
|
605
|
+
// Generate PDF (background job)
|
|
606
|
+
const pdfUrl = await generateStatementPDF(statement);
|
|
607
|
+
pdfVersion = await db.statementPdf.create({
|
|
608
|
+
data: {
|
|
609
|
+
statementId: statement.statementId,
|
|
610
|
+
version: statement.version,
|
|
611
|
+
versionToken: statement.versionToken,
|
|
612
|
+
pdfUrl,
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { url: pdfVersion.pdfUrl, version: pdfVersion.version };
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
## Audit Trail
|
|
622
|
+
|
|
623
|
+
Maintain an immutable audit log of all financial operations.
|
|
624
|
+
|
|
625
|
+
### Audit Log Schema
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
interface AuditLogEntry {
|
|
629
|
+
id: string;
|
|
630
|
+
timestamp: string; // ISO 8601
|
|
631
|
+
eventType: string; // LEDGER_CREATED, SETTLEMENT_APPLIED, STATEMENT_GENERATED, etc.
|
|
632
|
+
actorUserId: string;
|
|
633
|
+
buildingId: string;
|
|
634
|
+
unitId?: string;
|
|
635
|
+
requestId: string; // Idempotency key
|
|
636
|
+
|
|
637
|
+
// Sanitized payload (no PII)
|
|
638
|
+
payload: {
|
|
639
|
+
ledgerEntryId?: string;
|
|
640
|
+
entryType?: string;
|
|
641
|
+
amount?: number;
|
|
642
|
+
currency?: string;
|
|
643
|
+
settlementId?: string;
|
|
644
|
+
obligationIds?: string[];
|
|
645
|
+
// No personal documents, IDs, or sensitive data
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
ipAddress: string;
|
|
649
|
+
userAgent: string;
|
|
650
|
+
status: 'SUCCESS' | 'FAILURE';
|
|
651
|
+
errorMessage?: string;
|
|
652
|
+
}
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### Recording
|
|
656
|
+
|
|
657
|
+
```typescript
|
|
658
|
+
async function auditLog(entry: AuditLogEntry) {
|
|
659
|
+
// Always append, never modify
|
|
660
|
+
await db.auditLog.create({ data: entry });
|
|
661
|
+
|
|
662
|
+
// For critical events, also log to external system (e.g., Sentry, CloudWatch)
|
|
663
|
+
if (['SETTLEMENT_APPLIED', 'REVERSAL_CREATED'].includes(entry.eventType)) {
|
|
664
|
+
logger.critical('Financial operation', entry);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
### Retention
|
|
670
|
+
|
|
671
|
+
- **Minimum**: 7 years (regulatory requirement in many jurisdictions)
|
|
672
|
+
- **Immutable**: No DELETE or UPDATE
|
|
673
|
+
- **Queryable**: Indexed by building, unit, actor, event_type, timestamp
|
|
674
|
+
- **PII-free**: No document content, national IDs, or sensitive personal data
|
|
675
|
+
|
|
676
|
+
### Queries
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
// Find all operations by a user in a time range
|
|
680
|
+
const userActions = await db.auditLog.findMany({
|
|
681
|
+
where: {
|
|
682
|
+
actorUserId,
|
|
683
|
+
timestamp: { gte: startDate, lte: endDate },
|
|
684
|
+
},
|
|
685
|
+
orderBy: { timestamp: 'desc' },
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Find all settlements in a building
|
|
689
|
+
const settlements = await db.auditLog.findMany({
|
|
690
|
+
where: {
|
|
691
|
+
buildingId,
|
|
692
|
+
eventType: 'SETTLEMENT_APPLIED',
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Compliance: Get all audit logs for a 7-year period
|
|
697
|
+
const complianceData = await db.auditLog.findMany({
|
|
698
|
+
where: {
|
|
699
|
+
timestamp: { gte: sevenYearsAgo },
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
## Testing Financial Logic
|
|
705
|
+
|
|
706
|
+
Financial code has zero tolerance for bugs. Testing is mandatory, not optional.
|
|
707
|
+
|
|
708
|
+
### Unit Tests
|
|
709
|
+
|
|
710
|
+
```typescript
|
|
711
|
+
describe('FIFO Settlement', () => {
|
|
712
|
+
it('settles oldest obligation first', async () => {
|
|
713
|
+
// Arrange
|
|
714
|
+
const unit = await createTestUnit();
|
|
715
|
+
const oldObligation = await createObligation(unit.id, 100, daysAgo(30));
|
|
716
|
+
const newObligation = await createObligation(unit.id, 50, daysAgo(1));
|
|
717
|
+
const credit = 100;
|
|
718
|
+
|
|
719
|
+
// Act
|
|
720
|
+
const settlements = await settleObligations(unit.id, credit, 'JOD');
|
|
721
|
+
|
|
722
|
+
// Assert
|
|
723
|
+
expect(settlements).toHaveLength(1);
|
|
724
|
+
expect(settlements[0].obligationId).toBe(oldObligation.id);
|
|
725
|
+
expect(settlements[0].amount).toBe(100);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('skips if credit is insufficient for full settlement', async () => {
|
|
729
|
+
const unit = await createTestUnit();
|
|
730
|
+
const obligation = await createObligation(unit.id, 100, daysAgo(30));
|
|
731
|
+
const credit = 50; // Less than obligation
|
|
732
|
+
|
|
733
|
+
const settlements = await settleObligations(unit.id, credit, 'JOD');
|
|
734
|
+
|
|
735
|
+
expect(settlements).toHaveLength(0); // No partial settlement
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('settles multiple obligations atomically', async () => {
|
|
739
|
+
const unit = await createTestUnit();
|
|
740
|
+
await createObligation(unit.id, 50, daysAgo(30));
|
|
741
|
+
await createObligation(unit.id, 50, daysAgo(15));
|
|
742
|
+
const credit = 100;
|
|
743
|
+
|
|
744
|
+
const settlements = await settleObligations(unit.id, credit, 'JOD');
|
|
745
|
+
|
|
746
|
+
expect(settlements).toHaveLength(2);
|
|
747
|
+
expect(settlements[0].amount).toBe(50);
|
|
748
|
+
expect(settlements[1].amount).toBe(50);
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
describe('Idempotency', () => {
|
|
753
|
+
it('returns same result on duplicate request', async () => {
|
|
754
|
+
const key = uuidv4();
|
|
755
|
+
|
|
756
|
+
const first = await createLedgerEntry({
|
|
757
|
+
unitId: 'unit-1',
|
|
758
|
+
type: 'CREDIT_TOPUP',
|
|
759
|
+
amount: 1000,
|
|
760
|
+
currency: 'JOD',
|
|
761
|
+
idempotencyKey: key,
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const duplicate = await createLedgerEntry({
|
|
765
|
+
unitId: 'unit-1',
|
|
766
|
+
type: 'CREDIT_TOPUP',
|
|
767
|
+
amount: 1000,
|
|
768
|
+
currency: 'JOD',
|
|
769
|
+
idempotencyKey: key,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
expect(duplicate.id).toBe(first.id);
|
|
773
|
+
expect(duplicate.createdAt).toBe(first.createdAt);
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
describe('Money Handling', () => {
|
|
778
|
+
it('compares money correctly', () => {
|
|
779
|
+
const a = { amount: 10050, currency: 'USD' };
|
|
780
|
+
const b = { amount: 10050, currency: 'USD' };
|
|
781
|
+
|
|
782
|
+
expect(moneyEqual(a, b)).toBe(true);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('rejects different currencies', () => {
|
|
786
|
+
const a = { amount: 10050, currency: 'USD' };
|
|
787
|
+
const b = { amount: 10050, currency: 'JOD' };
|
|
788
|
+
|
|
789
|
+
expect(moneyEqual(a, b)).toBe(false);
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
describe('Reversal', () => {
|
|
794
|
+
it('negates original entry', async () => {
|
|
795
|
+
const original = await createLedgerEntry({
|
|
796
|
+
unitId: 'unit-1',
|
|
797
|
+
type: 'CREDIT_TOPUP',
|
|
798
|
+
amount: 1000,
|
|
799
|
+
currency: 'JOD',
|
|
800
|
+
idempotencyKey: uuidv4(),
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
const reversal = await createReversal(original.id);
|
|
804
|
+
|
|
805
|
+
const balance = await getUnitBalance('unit-1');
|
|
806
|
+
expect(balance).toBe(0);
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### Integration Tests
|
|
812
|
+
|
|
813
|
+
```typescript
|
|
814
|
+
describe('Auto-Settlement Job', () => {
|
|
815
|
+
it('settles all units with available credit', async () => {
|
|
816
|
+
const building = await createTestBuilding();
|
|
817
|
+
const [unit1, unit2] = await createTestUnits(building.id, 2);
|
|
818
|
+
|
|
819
|
+
// Unit 1: has credit and obligations
|
|
820
|
+
await createLedgerEntry({
|
|
821
|
+
unitId: unit1.id,
|
|
822
|
+
type: 'CREDIT_TOPUP',
|
|
823
|
+
amount: 200,
|
|
824
|
+
currency: 'JOD',
|
|
825
|
+
idempotencyKey: uuidv4(),
|
|
826
|
+
});
|
|
827
|
+
await createObligation(unit1.id, 100, daysAgo(30));
|
|
828
|
+
|
|
829
|
+
// Unit 2: no credit
|
|
830
|
+
await createObligation(unit2.id, 100, daysAgo(30));
|
|
831
|
+
|
|
832
|
+
const result = await runAutoSettlementJob(`settlement-${today()}`);
|
|
833
|
+
|
|
834
|
+
expect(result.status).toBe('COMPLETED');
|
|
835
|
+
expect(result.settlementsApplied).toBe(1);
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
### Test Data Cleanup
|
|
841
|
+
|
|
842
|
+
```typescript
|
|
843
|
+
afterEach(async () => {
|
|
844
|
+
// Clean up in reverse order of creation (foreign keys)
|
|
845
|
+
await db.settlement.deleteMany({ where: { unitId: testUnitId } });
|
|
846
|
+
await db.obligation.deleteMany({ where: { unitId: testUnitId } });
|
|
847
|
+
await db.ledgerEntry.deleteMany({ where: { unitId: testUnitId } });
|
|
848
|
+
await db.unit.delete({ where: { id: testUnitId } });
|
|
849
|
+
});
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
## Common Mistakes (and How to Avoid Them)
|
|
853
|
+
|
|
854
|
+
### ❌ Mistake 1: UPDATE on Ledger Entries
|
|
855
|
+
|
|
856
|
+
```typescript
|
|
857
|
+
// WRONG:
|
|
858
|
+
await db.ledgerEntry.update({
|
|
859
|
+
where: { id: 'entry-123' },
|
|
860
|
+
data: { amount: 1050 }, // Oops, typo fixed
|
|
861
|
+
});
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
**Why it's wrong**: Destroys audit trail, hides the original error, violates accounting standards.
|
|
865
|
+
|
|
866
|
+
**Solution**: Create a REVERSAL entry.
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
// RIGHT:
|
|
870
|
+
await createLedgerEntry({
|
|
871
|
+
unitId: entry.unitId,
|
|
872
|
+
type: 'REVERSAL',
|
|
873
|
+
amount: entry.amount,
|
|
874
|
+
currency: entry.currency,
|
|
875
|
+
description: `Reversal of entry ${entry.id} (typo)`,
|
|
876
|
+
metadata: { reversalOf: entry.id },
|
|
877
|
+
idempotencyKey: uuidv4(),
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Then create the correct entry:
|
|
881
|
+
await createLedgerEntry({
|
|
882
|
+
unitId: entry.unitId,
|
|
883
|
+
type: entry.type,
|
|
884
|
+
amount: 1050, // Corrected
|
|
885
|
+
currency: entry.currency,
|
|
886
|
+
description: `Corrected amount for ${entry.description}`,
|
|
887
|
+
idempotencyKey: uuidv4(),
|
|
888
|
+
});
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
### ❌ Mistake 2: Missing Idempotency Key
|
|
892
|
+
|
|
893
|
+
```typescript
|
|
894
|
+
// WRONG:
|
|
895
|
+
async function topupCredit(unitId: string, amount: number) {
|
|
896
|
+
// No idempotency key! Network retry = double charge
|
|
897
|
+
return await db.ledgerEntry.create({
|
|
898
|
+
unitId,
|
|
899
|
+
type: 'CREDIT_TOPUP',
|
|
900
|
+
amount,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
**Solution**: Always generate and store idempotency key.
|
|
906
|
+
|
|
907
|
+
```typescript
|
|
908
|
+
// RIGHT:
|
|
909
|
+
async function topupCredit(
|
|
910
|
+
unitId: string,
|
|
911
|
+
amount: number,
|
|
912
|
+
idempotencyKey: string // Required parameter
|
|
913
|
+
) {
|
|
914
|
+
const existing = await db.ledgerEntry.findFirst({
|
|
915
|
+
where: { requestId: idempotencyKey },
|
|
916
|
+
});
|
|
917
|
+
if (existing) return existing;
|
|
918
|
+
|
|
919
|
+
return await db.ledgerEntry.create({
|
|
920
|
+
unitId,
|
|
921
|
+
type: 'CREDIT_TOPUP',
|
|
922
|
+
amount,
|
|
923
|
+
requestId: idempotencyKey,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
### ❌ Mistake 3: Partial Settlement
|
|
929
|
+
|
|
930
|
+
```typescript
|
|
931
|
+
// WRONG:
|
|
932
|
+
let remaining = availableCredit;
|
|
933
|
+
for (const obligation of obligations) {
|
|
934
|
+
const settled = Math.min(remaining, obligation.amount);
|
|
935
|
+
settlements.push(settled); // Partial settlement!
|
|
936
|
+
remaining -= settled;
|
|
937
|
+
}
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
**Why it's wrong**: FIFO rule is all-or-nothing. Partial settlement breaks the principle and creates confusion.
|
|
941
|
+
|
|
942
|
+
**Solution**: Skip if not enough.
|
|
943
|
+
|
|
944
|
+
```typescript
|
|
945
|
+
// RIGHT:
|
|
946
|
+
for (const obligation of obligations) {
|
|
947
|
+
if (remainingCredit >= obligation.amount) {
|
|
948
|
+
settlements.push(obligation.amount);
|
|
949
|
+
remainingCredit -= obligation.amount;
|
|
950
|
+
}
|
|
951
|
+
// If not enough, skip entirely
|
|
952
|
+
}
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
### ❌ Mistake 4: Storing Amount Without Currency
|
|
956
|
+
|
|
957
|
+
```typescript
|
|
958
|
+
// WRONG:
|
|
959
|
+
const balance = 10050;
|
|
960
|
+
const newBalance = balance + 500;
|
|
961
|
+
// Is this JOD? USD? What's the unit?
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
**Solution**: Always pair amount with currency.
|
|
965
|
+
|
|
966
|
+
```typescript
|
|
967
|
+
// RIGHT:
|
|
968
|
+
const balance: Money = { amount: 10050, currency: 'JOD' };
|
|
969
|
+
const addition: Money = { amount: 500, currency: 'JOD' };
|
|
970
|
+
const newBalance = addMoney(balance, addition);
|
|
971
|
+
|
|
972
|
+
function addMoney(a: Money, b: Money): Money {
|
|
973
|
+
if (a.currency !== b.currency) {
|
|
974
|
+
throw new Error(`Cannot add ${a.currency} and ${b.currency}`);
|
|
975
|
+
}
|
|
976
|
+
return { amount: a.amount + b.amount, currency: a.currency };
|
|
977
|
+
}
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
### ❌ Mistake 5: Using JavaScript Floating Point
|
|
981
|
+
|
|
982
|
+
```typescript
|
|
983
|
+
// WRONG:
|
|
984
|
+
const amount = 10.05; // JavaScript floating point ❌
|
|
985
|
+
const result = amount + 0.01; // Might be 10.060000000000001
|
|
986
|
+
if (result === 10.06) { } // Fails due to precision
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
**Solution**: Use integer cents/fils.
|
|
990
|
+
|
|
991
|
+
```typescript
|
|
992
|
+
// RIGHT:
|
|
993
|
+
const amount = 1005; // 10.05 JOD in fils
|
|
994
|
+
const result = amount + 1; // Always precise
|
|
995
|
+
if (result === 1006) { } // ✓ Works
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### ❌ Mistake 6: Missing Transaction Wrapping
|
|
999
|
+
|
|
1000
|
+
```typescript
|
|
1001
|
+
// WRONG:
|
|
1002
|
+
const settlements = [];
|
|
1003
|
+
for (const obligation of obligations) {
|
|
1004
|
+
// If this fails halfway through, some are settled, some aren't
|
|
1005
|
+
await settleObligation(obligation);
|
|
1006
|
+
settlements.push(obligation);
|
|
1007
|
+
}
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
**Solution**: Wrap in a transaction.
|
|
1011
|
+
|
|
1012
|
+
```typescript
|
|
1013
|
+
// RIGHT:
|
|
1014
|
+
await db.$transaction(async (tx) => {
|
|
1015
|
+
for (const obligation of obligations) {
|
|
1016
|
+
await tx.settlement.create({
|
|
1017
|
+
obligationId: obligation.id,
|
|
1018
|
+
settledAt: new Date(),
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
// All succeed or all fail
|
|
1022
|
+
});
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
---
|
|
1026
|
+
|
|
1027
|
+
## Summary
|
|
1028
|
+
|
|
1029
|
+
Financial code is special. It requires:
|
|
1030
|
+
|
|
1031
|
+
1. **Append-only ledgers** — immutability, full audit trail
|
|
1032
|
+
2. **Idempotent writes** — prevent duplicates and double-charges
|
|
1033
|
+
3. **Integer money** — never floating point
|
|
1034
|
+
4. **FIFO settlement** — deterministic, fair, auditable
|
|
1035
|
+
5. **Comprehensive testing** — edge cases, idempotency, reversals
|
|
1036
|
+
6. **Immutable audit logs** — 7+ year retention
|
|
1037
|
+
7. **Transactions** — atomic multi-step operations
|
|
1038
|
+
|
|
1039
|
+
Every principle here comes from real banking failures. Follow them rigorously.
|