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