qualia-framework 2.6.0 → 3.2.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.
Files changed (321) hide show
  1. package/CLAUDE.md +64 -0
  2. package/README.md +103 -30
  3. package/agents/builder.md +110 -0
  4. package/agents/planner.md +134 -0
  5. package/agents/qa-browser.md +186 -0
  6. package/agents/verifier.md +221 -0
  7. package/bin/cli.js +336 -531
  8. package/bin/install.js +570 -0
  9. package/bin/qualia-ui.js +299 -0
  10. package/bin/state.js +630 -0
  11. package/bin/statusline.js +252 -0
  12. package/guide.md +63 -0
  13. package/hooks/auto-update.js +139 -0
  14. package/hooks/branch-guard.js +47 -0
  15. package/hooks/migration-guard.js +60 -0
  16. package/hooks/pre-compact.js +32 -0
  17. package/hooks/pre-deploy-gate.js +110 -0
  18. package/hooks/pre-push.js +33 -0
  19. package/hooks/session-start.js +170 -0
  20. package/package.json +29 -20
  21. package/rules/design-reference.md +179 -0
  22. package/rules/frontend.md +126 -0
  23. package/skills/qualia/SKILL.md +87 -0
  24. package/skills/qualia-build/SKILL.md +97 -0
  25. package/skills/qualia-debug/SKILL.md +87 -0
  26. package/skills/qualia-design/SKILL.md +93 -0
  27. package/skills/qualia-handoff/SKILL.md +66 -0
  28. package/skills/qualia-idk/SKILL.md +8 -0
  29. package/skills/qualia-learn/SKILL.md +88 -0
  30. package/skills/qualia-new/SKILL.md +323 -0
  31. package/{framework/skills → skills}/qualia-optimize/SKILL.md +1 -1
  32. package/skills/qualia-pause/SKILL.md +63 -0
  33. package/skills/qualia-plan/SKILL.md +101 -0
  34. package/skills/qualia-polish/SKILL.md +157 -0
  35. package/skills/qualia-quick/SKILL.md +37 -0
  36. package/skills/qualia-report/SKILL.md +105 -0
  37. package/skills/qualia-resume/SKILL.md +49 -0
  38. package/skills/qualia-review/SKILL.md +76 -0
  39. package/skills/qualia-ship/SKILL.md +90 -0
  40. package/skills/qualia-skill-new/SKILL.md +167 -0
  41. package/skills/qualia-task/SKILL.md +91 -0
  42. package/skills/qualia-verify/SKILL.md +113 -0
  43. package/templates/DESIGN.md +137 -0
  44. package/templates/plan.md +28 -0
  45. package/templates/project.md +22 -0
  46. package/templates/state.md +27 -0
  47. package/templates/tracking.json +20 -0
  48. package/tests/bin.test.sh +673 -0
  49. package/tests/hooks.test.sh +315 -0
  50. package/tests/state.test.sh +535 -0
  51. package/tests/statusline.test.sh +243 -0
  52. package/bin/collect-metrics.sh +0 -62
  53. package/framework/.claudeignore +0 -51
  54. package/framework/CLAUDE.md +0 -51
  55. package/framework/MCP_SETUP.md +0 -229
  56. package/framework/agents/architecture-strategist.md +0 -53
  57. package/framework/agents/backend-agent.md +0 -150
  58. package/framework/agents/code-simplicity-reviewer.md +0 -86
  59. package/framework/agents/frontend-agent.md +0 -111
  60. package/framework/agents/kieran-typescript-reviewer.md +0 -96
  61. package/framework/agents/performance-oracle.md +0 -111
  62. package/framework/agents/qualia-codebase-mapper.md +0 -761
  63. package/framework/agents/qualia-debugger.md +0 -1204
  64. package/framework/agents/qualia-executor.md +0 -882
  65. package/framework/agents/qualia-integration-checker.md +0 -424
  66. package/framework/agents/qualia-phase-researcher.md +0 -457
  67. package/framework/agents/qualia-plan-checker.md +0 -700
  68. package/framework/agents/qualia-planner.md +0 -1245
  69. package/framework/agents/qualia-project-researcher.md +0 -603
  70. package/framework/agents/qualia-research-synthesizer.md +0 -200
  71. package/framework/agents/qualia-roadmapper.md +0 -606
  72. package/framework/agents/qualia-verifier.md +0 -686
  73. package/framework/agents/red-team-qa.md +0 -130
  74. package/framework/agents/security-auditor.md +0 -72
  75. package/framework/agents/team-orchestrator.md +0 -229
  76. package/framework/agents/teams/framework-audit-team.md +0 -66
  77. package/framework/agents/teams/full-stack-team.md +0 -48
  78. package/framework/agents/teams/optimize-team.md +0 -53
  79. package/framework/agents/teams/review-team.md +0 -70
  80. package/framework/agents/teams/ship-team.md +0 -86
  81. package/framework/agents/test-agent.md +0 -182
  82. package/framework/hooks/auto-format.sh +0 -54
  83. package/framework/hooks/block-env-edit.sh +0 -42
  84. package/framework/hooks/branch-guard.sh +0 -43
  85. package/framework/hooks/confirm-delete.sh +0 -59
  86. package/framework/hooks/migration-validate.sh +0 -77
  87. package/framework/hooks/notification-speak.sh +0 -16
  88. package/framework/hooks/pre-commit.sh +0 -100
  89. package/framework/hooks/pre-compact.sh +0 -56
  90. package/framework/hooks/pre-deploy-gate.sh +0 -160
  91. package/framework/hooks/qualia-colors.sh +0 -32
  92. package/framework/hooks/retention-cleanup.sh +0 -62
  93. package/framework/hooks/save-session-state.sh +0 -185
  94. package/framework/hooks/session-context-loader.sh +0 -96
  95. package/framework/hooks/session-learn.sh +0 -32
  96. package/framework/hooks/skill-announce.sh +0 -123
  97. package/framework/hooks/tool-error-announce.sh +0 -27
  98. package/framework/install.ps1 +0 -323
  99. package/framework/install.sh +0 -313
  100. package/framework/qualia-framework/VERSION +0 -1
  101. package/framework/qualia-framework/assets/qualia-logo.png +0 -0
  102. package/framework/qualia-framework/bin/collect-metrics.sh +0 -67
  103. package/framework/qualia-framework/bin/generate-report-docx.py +0 -429
  104. package/framework/qualia-framework/bin/qualia-tools.js +0 -2201
  105. package/framework/qualia-framework/bin/qualia-tools.test.js +0 -1054
  106. package/framework/qualia-framework/references/checkpoints.md +0 -775
  107. package/framework/qualia-framework/references/completion-checklists.md +0 -359
  108. package/framework/qualia-framework/references/continuation-format.md +0 -249
  109. package/framework/qualia-framework/references/continuation-prompt.md +0 -97
  110. package/framework/qualia-framework/references/decimal-phase-calculation.md +0 -65
  111. package/framework/qualia-framework/references/design-quality.md +0 -56
  112. package/framework/qualia-framework/references/employee-guide.md +0 -167
  113. package/framework/qualia-framework/references/git-integration.md +0 -254
  114. package/framework/qualia-framework/references/git-planning-commit.md +0 -50
  115. package/framework/qualia-framework/references/model-profile-resolution.md +0 -32
  116. package/framework/qualia-framework/references/model-profiles.md +0 -73
  117. package/framework/qualia-framework/references/phase-argument-parsing.md +0 -61
  118. package/framework/qualia-framework/references/planning-config.md +0 -195
  119. package/framework/qualia-framework/references/questioning.md +0 -141
  120. package/framework/qualia-framework/references/tdd.md +0 -263
  121. package/framework/qualia-framework/references/ui-brand.md +0 -160
  122. package/framework/qualia-framework/references/verification-patterns.md +0 -612
  123. package/framework/qualia-framework/templates/DEBUG.md +0 -159
  124. package/framework/qualia-framework/templates/DESIGN.md +0 -81
  125. package/framework/qualia-framework/templates/UAT.md +0 -247
  126. package/framework/qualia-framework/templates/codebase/architecture.md +0 -255
  127. package/framework/qualia-framework/templates/codebase/concerns.md +0 -310
  128. package/framework/qualia-framework/templates/codebase/conventions.md +0 -307
  129. package/framework/qualia-framework/templates/codebase/integrations.md +0 -280
  130. package/framework/qualia-framework/templates/codebase/stack.md +0 -186
  131. package/framework/qualia-framework/templates/codebase/structure.md +0 -285
  132. package/framework/qualia-framework/templates/codebase/testing.md +0 -480
  133. package/framework/qualia-framework/templates/config.json +0 -35
  134. package/framework/qualia-framework/templates/context.md +0 -283
  135. package/framework/qualia-framework/templates/continue-here.md +0 -78
  136. package/framework/qualia-framework/templates/debug-subagent-prompt.md +0 -91
  137. package/framework/qualia-framework/templates/discovery.md +0 -146
  138. package/framework/qualia-framework/templates/lab-notes.md +0 -16
  139. package/framework/qualia-framework/templates/milestone-archive.md +0 -123
  140. package/framework/qualia-framework/templates/milestone.md +0 -115
  141. package/framework/qualia-framework/templates/phase-prompt.md +0 -567
  142. package/framework/qualia-framework/templates/planner-subagent-prompt.md +0 -117
  143. package/framework/qualia-framework/templates/project.md +0 -184
  144. package/framework/qualia-framework/templates/projects/ai-agent.md +0 -156
  145. package/framework/qualia-framework/templates/projects/mobile-app.md +0 -181
  146. package/framework/qualia-framework/templates/projects/voice-agent.md +0 -134
  147. package/framework/qualia-framework/templates/projects/website.md +0 -137
  148. package/framework/qualia-framework/templates/requirements.md +0 -231
  149. package/framework/qualia-framework/templates/research-project/ARCHITECTURE.md +0 -204
  150. package/framework/qualia-framework/templates/research-project/FEATURES.md +0 -147
  151. package/framework/qualia-framework/templates/research-project/PITFALLS.md +0 -200
  152. package/framework/qualia-framework/templates/research-project/STACK.md +0 -120
  153. package/framework/qualia-framework/templates/research-project/SUMMARY.md +0 -170
  154. package/framework/qualia-framework/templates/research.md +0 -552
  155. package/framework/qualia-framework/templates/roadmap.md +0 -206
  156. package/framework/qualia-framework/templates/state.md +0 -179
  157. package/framework/qualia-framework/templates/summary-complex.md +0 -59
  158. package/framework/qualia-framework/templates/summary-minimal.md +0 -41
  159. package/framework/qualia-framework/templates/summary-standard.md +0 -48
  160. package/framework/qualia-framework/templates/summary.md +0 -246
  161. package/framework/qualia-framework/templates/user-setup.md +0 -311
  162. package/framework/qualia-framework/templates/verification-report.md +0 -322
  163. package/framework/qualia-framework/workflows/add-phase.md +0 -179
  164. package/framework/qualia-framework/workflows/add-todo.md +0 -157
  165. package/framework/qualia-framework/workflows/audit-milestone.md +0 -241
  166. package/framework/qualia-framework/workflows/check-todos.md +0 -176
  167. package/framework/qualia-framework/workflows/complete-milestone.md +0 -858
  168. package/framework/qualia-framework/workflows/diagnose-issues.md +0 -219
  169. package/framework/qualia-framework/workflows/discovery-phase.md +0 -289
  170. package/framework/qualia-framework/workflows/discuss-phase.md +0 -534
  171. package/framework/qualia-framework/workflows/execute-phase.md +0 -559
  172. package/framework/qualia-framework/workflows/execute-plan.md +0 -438
  173. package/framework/qualia-framework/workflows/help.md +0 -470
  174. package/framework/qualia-framework/workflows/insert-phase.md +0 -220
  175. package/framework/qualia-framework/workflows/list-phase-assumptions.md +0 -178
  176. package/framework/qualia-framework/workflows/map-codebase.md +0 -327
  177. package/framework/qualia-framework/workflows/new-milestone.md +0 -363
  178. package/framework/qualia-framework/workflows/new-project.md +0 -982
  179. package/framework/qualia-framework/workflows/pause-work.md +0 -122
  180. package/framework/qualia-framework/workflows/plan-milestone-gaps.md +0 -256
  181. package/framework/qualia-framework/workflows/plan-phase.md +0 -422
  182. package/framework/qualia-framework/workflows/progress.md +0 -389
  183. package/framework/qualia-framework/workflows/quick.md +0 -252
  184. package/framework/qualia-framework/workflows/remove-phase.md +0 -326
  185. package/framework/qualia-framework/workflows/research-phase.md +0 -74
  186. package/framework/qualia-framework/workflows/resume-project.md +0 -306
  187. package/framework/qualia-framework/workflows/set-profile.md +0 -80
  188. package/framework/qualia-framework/workflows/settings.md +0 -145
  189. package/framework/qualia-framework/workflows/transition.md +0 -556
  190. package/framework/qualia-framework/workflows/update.md +0 -197
  191. package/framework/qualia-framework/workflows/verify-phase.md +0 -195
  192. package/framework/qualia-framework/workflows/verify-work.md +0 -625
  193. package/framework/rules/context7.md +0 -14
  194. package/framework/rules/frontend.md +0 -33
  195. package/framework/rules/speed.md +0 -23
  196. package/framework/scripts/__pycache__/say.cpython-314.pyc +0 -0
  197. package/framework/scripts/apply-retention.sh +0 -120
  198. package/framework/scripts/bootstrap-pop-os.sh +0 -354
  199. package/framework/scripts/claude-voice +0 -13
  200. package/framework/scripts/cleanup.sh +0 -131
  201. package/framework/scripts/cowork-mode.sh +0 -141
  202. package/framework/scripts/generate-project-claude-md.sh +0 -153
  203. package/framework/scripts/load-test-webhook.js +0 -172
  204. package/framework/scripts/say.py +0 -236
  205. package/framework/scripts/showcase-video-recorder/ffmpeg-builder.js +0 -167
  206. package/framework/scripts/showcase-video-recorder/playwright-helpers.js +0 -216
  207. package/framework/scripts/speak.py +0 -55
  208. package/framework/scripts/speak.sh +0 -18
  209. package/framework/scripts/status.sh +0 -138
  210. package/framework/scripts/sync-to-framework.sh +0 -65
  211. package/framework/scripts/voice-hotkey.py +0 -227
  212. package/framework/scripts/voice-input.sh +0 -51
  213. package/framework/skills/animate/SKILL.md +0 -202
  214. package/framework/skills/bolder/SKILL.md +0 -144
  215. package/framework/skills/browser-qa/SKILL.md +0 -536
  216. package/framework/skills/clarify/SKILL.md +0 -179
  217. package/framework/skills/client-handoff/SKILL.md +0 -135
  218. package/framework/skills/collab-onboard/SKILL.md +0 -111
  219. package/framework/skills/colorize/SKILL.md +0 -170
  220. package/framework/skills/critique/SKILL.md +0 -126
  221. package/framework/skills/deep-research/SKILL.md +0 -240
  222. package/framework/skills/delight/SKILL.md +0 -329
  223. package/framework/skills/deploy/SKILL.md +0 -261
  224. package/framework/skills/deploy-verify/SKILL.md +0 -377
  225. package/framework/skills/deploy-verify/scripts/canary-check.sh +0 -206
  226. package/framework/skills/deploy-verify/scripts/check-console-errors.js +0 -147
  227. package/framework/skills/deploy-verify/scripts/check-cwv.js +0 -139
  228. package/framework/skills/deploy-verify/scripts/project-detect.sh +0 -84
  229. package/framework/skills/deploy-verify/scripts/verify.sh +0 -548
  230. package/framework/skills/design-quieter/SKILL.md +0 -130
  231. package/framework/skills/distill/SKILL.md +0 -149
  232. package/framework/skills/docs-lookup/SKILL.md +0 -79
  233. package/framework/skills/fcm-notifications/SKILL.md +0 -125
  234. package/framework/skills/financial-ledger/SKILL.md +0 -1039
  235. package/framework/skills/frontend-master/NOTICE.md +0 -4
  236. package/framework/skills/frontend-master/SKILL.md +0 -127
  237. package/framework/skills/frontend-master/reference/color-and-contrast.md +0 -132
  238. package/framework/skills/frontend-master/reference/interaction-design.md +0 -123
  239. package/framework/skills/frontend-master/reference/motion-design.md +0 -99
  240. package/framework/skills/frontend-master/reference/responsive-design.md +0 -114
  241. package/framework/skills/frontend-master/reference/spatial-design.md +0 -100
  242. package/framework/skills/frontend-master/reference/typography.md +0 -131
  243. package/framework/skills/frontend-master/reference/ux-writing.md +0 -107
  244. package/framework/skills/harden/SKILL.md +0 -357
  245. package/framework/skills/i18n-rtl/SKILL.md +0 -752
  246. package/framework/skills/learn/SKILL.md +0 -95
  247. package/framework/skills/memory/SKILL.md +0 -50
  248. package/framework/skills/mobile-expo/SKILL.md +0 -977
  249. package/framework/skills/mobile-expo/references/store-checklist.md +0 -550
  250. package/framework/skills/nestjs-backend/README.md +0 -73
  251. package/framework/skills/nestjs-backend/SKILL.md +0 -446
  252. package/framework/skills/nestjs-backend/references/templates.md +0 -1173
  253. package/framework/skills/normalize/SKILL.md +0 -79
  254. package/framework/skills/onboard/SKILL.md +0 -242
  255. package/framework/skills/openrouter-agent/SKILL.md +0 -922
  256. package/framework/skills/polish/SKILL.md +0 -209
  257. package/framework/skills/pr/SKILL.md +0 -66
  258. package/framework/skills/qualia/SKILL.md +0 -199
  259. package/framework/skills/qualia-add-todo/SKILL.md +0 -68
  260. package/framework/skills/qualia-audit-milestone/SKILL.md +0 -95
  261. package/framework/skills/qualia-check-todos/SKILL.md +0 -55
  262. package/framework/skills/qualia-complete-milestone/SKILL.md +0 -134
  263. package/framework/skills/qualia-debug/SKILL.md +0 -149
  264. package/framework/skills/qualia-design/SKILL.md +0 -203
  265. package/framework/skills/qualia-discuss-phase/SKILL.md +0 -72
  266. package/framework/skills/qualia-evolve/SKILL.md +0 -200
  267. package/framework/skills/qualia-execute-phase/SKILL.md +0 -89
  268. package/framework/skills/qualia-framework-audit/SKILL.md +0 -604
  269. package/framework/skills/qualia-guide/SKILL.md +0 -32
  270. package/framework/skills/qualia-help/SKILL.md +0 -114
  271. package/framework/skills/qualia-idk/SKILL.md +0 -352
  272. package/framework/skills/qualia-list-phase-assumptions/SKILL.md +0 -67
  273. package/framework/skills/qualia-new-milestone/SKILL.md +0 -72
  274. package/framework/skills/qualia-new-project/SKILL.md +0 -232
  275. package/framework/skills/qualia-pause-work/SKILL.md +0 -96
  276. package/framework/skills/qualia-plan-milestone-gaps/SKILL.md +0 -57
  277. package/framework/skills/qualia-plan-phase/SKILL.md +0 -104
  278. package/framework/skills/qualia-production-check/SKILL.md +0 -0
  279. package/framework/skills/qualia-progress/SKILL.md +0 -53
  280. package/framework/skills/qualia-quick/SKILL.md +0 -89
  281. package/framework/skills/qualia-report/SKILL.md +0 -166
  282. package/framework/skills/qualia-research-phase/SKILL.md +0 -88
  283. package/framework/skills/qualia-resume-work/SKILL.md +0 -62
  284. package/framework/skills/qualia-review/SKILL.md +0 -263
  285. package/framework/skills/qualia-start/SKILL.md +0 -161
  286. package/framework/skills/qualia-verify-work/SKILL.md +0 -132
  287. package/framework/skills/rag/SKILL.md +0 -750
  288. package/framework/skills/responsive/SKILL.md +0 -231
  289. package/framework/skills/retro/SKILL.md +0 -284
  290. package/framework/skills/sakani-conventions/SKILL.md +0 -136
  291. package/framework/skills/sakani-conventions/evals/evals.json +0 -23
  292. package/framework/skills/sakani-conventions/references/entities.md +0 -365
  293. package/framework/skills/sakani-conventions/references/error-codes.md +0 -95
  294. package/framework/skills/seo-master/SKILL.md +0 -490
  295. package/framework/skills/seo-master/references/checklist.md +0 -199
  296. package/framework/skills/seo-master/references/structured-data.md +0 -609
  297. package/framework/skills/ship/SKILL.md +0 -239
  298. package/framework/skills/stack-researcher/SKILL.md +0 -215
  299. package/framework/skills/status/SKILL.md +0 -154
  300. package/framework/skills/status/scripts/health-check.sh +0 -562
  301. package/framework/skills/subscription-payments/SKILL.md +0 -250
  302. package/framework/skills/supabase/SKILL.md +0 -973
  303. package/framework/skills/supabase/references/templates.md +0 -159
  304. package/framework/skills/team/SKILL.md +0 -67
  305. package/framework/skills/test-runner/SKILL.md +0 -202
  306. package/framework/skills/voice-agent/SKILL.md +0 -1312
  307. package/framework/skills/zoho-workflow/SKILL.md +0 -51
  308. package/framework/statusline-command.sh +0 -117
  309. package/framework/teams/default/inboxes/plan-04.json +0 -9
  310. package/framework/teams/review-team.md +0 -75
  311. package/framework/teams/ship-team.md +0 -86
  312. package/profiles/fawzi.json +0 -16
  313. package/profiles/hasan.json +0 -16
  314. package/profiles/moayad.json +0 -16
  315. package/templates/CLAUDE-owner.md +0 -52
  316. package/templates/CLAUDE.md.hbs +0 -58
  317. package/templates/env.claude.template +0 -12
  318. package/templates/settings.json +0 -172
  319. package/uninstall.sh +0 -90
  320. /package/{framework/rules → rules}/deployment.md +0 -0
  321. /package/{framework/rules → rules}/security.md +0 -0
@@ -1,1039 +0,0 @@
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.