create-protobase 0.23.13 → 0.24.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 (240) hide show
  1. package/dist/ask-preset.js +25 -0
  2. package/dist/create-project.js +7 -6
  3. package/dist/index.js +19 -9
  4. package/dist/next-steps.js +8 -2
  5. package/dist/presets.js +17 -0
  6. package/dist/templates/erp/.build-test-abf2a11f/protobase.config.ts +6 -0
  7. package/dist/templates/erp/.build-test-abf2a11f/resources.ts +2 -0
  8. package/dist/templates/erp/.build-test-b878b26f/config/index.ts +2 -0
  9. package/dist/templates/erp/.build-test-b878b26f/config/things/data.ts +2 -0
  10. package/dist/templates/erp/.build-test-b878b26f/config/things/ui.ts +2 -0
  11. package/dist/templates/erp/.env.example +14 -0
  12. package/dist/templates/erp/AGENTS.md +24 -0
  13. package/dist/templates/erp/auth/auth.ts +68 -0
  14. package/dist/templates/erp/components/orders-per-day.tsx +21 -0
  15. package/dist/templates/erp/config/bottom-bar.ts +9 -0
  16. package/dist/templates/erp/config/categories/data.ts +20 -0
  17. package/dist/templates/erp/config/categories/ui.ts +11 -0
  18. package/dist/templates/erp/config/companies/data.ts +23 -0
  19. package/dist/templates/erp/config/companies/ui.ts +22 -0
  20. package/dist/templates/erp/config/company_tags/data.ts +16 -0
  21. package/dist/templates/erp/config/company_tags/ui.ts +7 -0
  22. package/dist/templates/erp/config/countries/data.ts +13 -0
  23. package/dist/templates/erp/config/countries/ui.ts +6 -0
  24. package/dist/templates/erp/config/currencies/data.ts +14 -0
  25. package/dist/templates/erp/config/currencies/ui.ts +6 -0
  26. package/dist/templates/erp/config/emp_master/data.ts +19 -0
  27. package/dist/templates/erp/config/emp_master/ui.ts +7 -0
  28. package/dist/templates/erp/config/index.ts +42 -0
  29. package/dist/templates/erp/config/invoice_lines/data.ts +23 -0
  30. package/dist/templates/erp/config/invoice_lines/ui.ts +7 -0
  31. package/dist/templates/erp/config/invoices/data.ts +26 -0
  32. package/dist/templates/erp/config/invoices/ui.ts +38 -0
  33. package/dist/templates/erp/config/jobs.test.ts +23 -0
  34. package/dist/templates/erp/config/jobs.ts +29 -0
  35. package/dist/templates/erp/config/locations/data.ts +18 -0
  36. package/dist/templates/erp/config/locations/ui.ts +6 -0
  37. package/dist/templates/erp/config/order_lines/data.ts +20 -0
  38. package/dist/templates/erp/config/order_lines/ui.ts +7 -0
  39. package/dist/templates/erp/config/orders/data.ts +27 -0
  40. package/dist/templates/erp/config/orders/ui.ts +41 -0
  41. package/dist/templates/erp/config/organizations/data.ts +17 -0
  42. package/dist/templates/erp/config/organizations/ui.ts +6 -0
  43. package/dist/templates/erp/config/overview/page.tsx +80 -0
  44. package/dist/templates/erp/config/pages.test.ts +14 -0
  45. package/dist/templates/erp/config/people/data.ts +21 -0
  46. package/dist/templates/erp/config/people/ui.ts +6 -0
  47. package/dist/templates/erp/config/products/data.ts +25 -0
  48. package/dist/templates/erp/config/products/ui.ts +6 -0
  49. package/dist/templates/erp/config/roles.test.ts +56 -0
  50. package/dist/templates/erp/config/roles.ts +54 -0
  51. package/dist/templates/erp/config/shell.test.ts +24 -0
  52. package/dist/templates/erp/config/stock_levels/data.ts +16 -0
  53. package/dist/templates/erp/config/stock_levels/ui.ts +6 -0
  54. package/dist/templates/erp/config/stock_moves/data.ts +22 -0
  55. package/dist/templates/erp/config/stock_moves/ui.ts +25 -0
  56. package/dist/templates/erp/config/tags/data.ts +17 -0
  57. package/dist/templates/erp/config/tags/ui.ts +6 -0
  58. package/dist/templates/erp/config/user-menu.ts +9 -0
  59. package/dist/templates/erp/config/users/data.ts +19 -0
  60. package/dist/templates/erp/config/users/ui.ts +9 -0
  61. package/dist/templates/erp/config/warehouses/data.ts +19 -0
  62. package/dist/templates/erp/config/warehouses/ui.ts +6 -0
  63. package/dist/templates/erp/data.ts +3 -0
  64. package/dist/templates/erp/db/connection.ts +7 -0
  65. package/dist/templates/erp/db/drop.ts +9 -0
  66. package/dist/templates/erp/db/kysely.ts +13 -0
  67. package/dist/templates/erp/db/migrations/001_core.sql +48 -0
  68. package/dist/templates/erp/db/migrations/002_crm.sql +59 -0
  69. package/dist/templates/erp/db/migrations/003_catalog.sql +38 -0
  70. package/dist/templates/erp/db/migrations/004_sales.sql +86 -0
  71. package/dist/templates/erp/db/migrations/005_inventory.sql +65 -0
  72. package/dist/templates/erp/db/migrations/006_hr_legacy.sql +16 -0
  73. package/dist/templates/erp/db/migrations/007_filter_indexes.sql +4 -0
  74. package/dist/templates/erp/db/migrations/008_calculated_totals.sql +98 -0
  75. package/dist/templates/erp/db/migrations/011_auth_organizations.sql +22 -0
  76. package/dist/templates/erp/db/migrations/012_jobs.sql +531 -0
  77. package/dist/templates/erp/db/migrations/013_jobs.sql +98 -0
  78. package/dist/templates/erp/db/migrations/016_jobs.sql +99 -0
  79. package/dist/templates/erp/db/migrations/017_filter_indexes.sql +5 -0
  80. package/dist/templates/erp/db/migrations/018_sort_indexes.sql +4 -0
  81. package/dist/templates/erp/db/migrations/019_auth_users.sql +18 -0
  82. package/dist/templates/erp/functions/unpaid-orders.ts +13 -0
  83. package/dist/templates/erp/package.json +38 -0
  84. package/dist/templates/erp/protobase.config.ts +22 -0
  85. package/dist/templates/erp/protobase.ui.tsx +14 -0
  86. package/dist/templates/erp/seed/auth-organizations.ts +11 -0
  87. package/dist/templates/erp/seed/base-organization.test.ts +17 -0
  88. package/dist/templates/erp/seed/base-organization.ts +10 -0
  89. package/dist/templates/erp/seed/base.ts +22 -0
  90. package/dist/templates/erp/seed/builders/categories.ts +18 -0
  91. package/dist/templates/erp/seed/builders/company.ts +61 -0
  92. package/dist/templates/erp/seed/builders/invoice.ts +31 -0
  93. package/dist/templates/erp/seed/builders/order.ts +66 -0
  94. package/dist/templates/erp/seed/builders/product.ts +46 -0
  95. package/dist/templates/erp/seed/calendar.ts +60 -0
  96. package/dist/templates/erp/seed/cli.ts +54 -0
  97. package/dist/templates/erp/seed/copy.ts +22 -0
  98. package/dist/templates/erp/seed/data/catalogTree.ts +72 -0
  99. package/dist/templates/erp/seed/data/layout.ts +19 -0
  100. package/dist/templates/erp/seed/data/names.ts +44 -0
  101. package/dist/templates/erp/seed/data/reference.ts +53 -0
  102. package/dist/templates/erp/seed/format.ts +24 -0
  103. package/dist/templates/erp/seed/maintenance.ts +63 -0
  104. package/dist/templates/erp/seed/rng.ts +36 -0
  105. package/dist/templates/erp/seed/scales.ts +9 -0
  106. package/dist/templates/erp/seed/steps/catalog.ts +34 -0
  107. package/dist/templates/erp/seed/steps/crm.ts +62 -0
  108. package/dist/templates/erp/seed/steps/hr.ts +26 -0
  109. package/dist/templates/erp/seed/steps/ledger.ts +88 -0
  110. package/dist/templates/erp/seed/steps/reference.ts +24 -0
  111. package/dist/templates/erp/seed/steps/sales.ts +58 -0
  112. package/dist/templates/erp/seed/steps/warehouses.ts +42 -0
  113. package/dist/templates/erp/seed/world.ts +86 -0
  114. package/dist/templates/erp/vitest.config.ts +4 -0
  115. package/dist/templates/real-estate/.env.example +9 -0
  116. package/dist/templates/real-estate/AGENTS.md +24 -0
  117. package/dist/templates/real-estate/_.gitignore +3 -0
  118. package/dist/templates/real-estate/_bunfig.toml +4 -0
  119. package/dist/templates/real-estate/auth/auth.ts +36 -0
  120. package/dist/templates/real-estate/config/amenities/data.ts +12 -0
  121. package/dist/templates/real-estate/config/amenities/ui.ts +7 -0
  122. package/dist/templates/real-estate/config/arrears/data.ts +19 -0
  123. package/dist/templates/real-estate/config/arrears/ui.ts +21 -0
  124. package/dist/templates/real-estate/config/deposits/data.ts +21 -0
  125. package/dist/templates/real-estate/config/deposits/ui.ts +17 -0
  126. package/dist/templates/real-estate/config/index.ts +41 -0
  127. package/dist/templates/real-estate/config/inspections/data.ts +22 -0
  128. package/dist/templates/real-estate/config/inspections/ui.ts +15 -0
  129. package/dist/templates/real-estate/config/lease_tenants/data.ts +16 -0
  130. package/dist/templates/real-estate/config/lease_tenants/ui.ts +8 -0
  131. package/dist/templates/real-estate/config/leases/data.ts +29 -0
  132. package/dist/templates/real-estate/config/leases/ui.ts +33 -0
  133. package/dist/templates/real-estate/config/organizations/data.ts +17 -0
  134. package/dist/templates/real-estate/config/organizations/ui.ts +7 -0
  135. package/dist/templates/real-estate/config/overview/page.tsx +57 -0
  136. package/dist/templates/real-estate/config/owners/data.ts +22 -0
  137. package/dist/templates/real-estate/config/owners/ui.ts +17 -0
  138. package/dist/templates/real-estate/config/pages.test.ts +49 -0
  139. package/dist/templates/real-estate/config/payments/data.ts +20 -0
  140. package/dist/templates/real-estate/config/payments/ui.ts +14 -0
  141. package/dist/templates/real-estate/config/properties/data.ts +26 -0
  142. package/dist/templates/real-estate/config/properties/ui.ts +21 -0
  143. package/dist/templates/real-estate/config/rent_charges/data.ts +22 -0
  144. package/dist/templates/real-estate/config/rent_charges/ui.ts +27 -0
  145. package/dist/templates/real-estate/config/roles.test.ts +49 -0
  146. package/dist/templates/real-estate/config/roles.ts +45 -0
  147. package/dist/templates/real-estate/config/shell.test.ts +18 -0
  148. package/dist/templates/real-estate/config/tenants/data.ts +23 -0
  149. package/dist/templates/real-estate/config/tenants/ui.ts +27 -0
  150. package/dist/templates/real-estate/config/tickets/data.ts +33 -0
  151. package/dist/templates/real-estate/config/tickets/ui.ts +58 -0
  152. package/dist/templates/real-estate/config/unit_amenities/data.ts +15 -0
  153. package/dist/templates/real-estate/config/unit_amenities/ui.ts +8 -0
  154. package/dist/templates/real-estate/config/units/data.ts +24 -0
  155. package/dist/templates/real-estate/config/units/ui.ts +41 -0
  156. package/dist/templates/real-estate/config/user-menu.ts +9 -0
  157. package/dist/templates/real-estate/config/users/data.ts +19 -0
  158. package/dist/templates/real-estate/config/users/ui.ts +11 -0
  159. package/dist/templates/real-estate/config/valuations/data.ts +19 -0
  160. package/dist/templates/real-estate/config/valuations/ui.ts +13 -0
  161. package/dist/templates/real-estate/config/vendors/data.ts +21 -0
  162. package/dist/templates/real-estate/config/vendors/ui.ts +18 -0
  163. package/dist/templates/real-estate/config/work_orders/data.ts +22 -0
  164. package/dist/templates/real-estate/config/work_orders/ui.ts +18 -0
  165. package/dist/templates/real-estate/db/connection.ts +7 -0
  166. package/dist/templates/real-estate/db/drop.ts +9 -0
  167. package/dist/templates/real-estate/db/kysely.ts +13 -0
  168. package/dist/templates/real-estate/db/migrate.ts +30 -0
  169. package/dist/templates/real-estate/db/migrations/001_core.sql +33 -0
  170. package/dist/templates/real-estate/db/migrations/002_portfolio.sql +97 -0
  171. package/dist/templates/real-estate/db/migrations/003_leasing.sql +78 -0
  172. package/dist/templates/real-estate/db/migrations/004_billing.sql +88 -0
  173. package/dist/templates/real-estate/db/migrations/005_maintenance.sql +105 -0
  174. package/dist/templates/real-estate/db/migrations/006_outstanding.sql +14 -0
  175. package/dist/templates/real-estate/db/migrations/007_auth.sql +36 -0
  176. package/dist/templates/real-estate/db/migrations/008_auth_snake_case.sql +109 -0
  177. package/dist/templates/real-estate/db/migrations/009_auth.sql +6 -0
  178. package/dist/templates/real-estate/db/migrations/010_auth.sql +4 -0
  179. package/dist/templates/real-estate/db/migrations/011_filter_indexes.sql +8 -0
  180. package/dist/templates/real-estate/db/migrations/012_sort_indexes.sql +2 -0
  181. package/dist/templates/real-estate/db/migrations/013_auth_users.sql +17 -0
  182. package/dist/templates/real-estate/node-types.d.ts +1 -0
  183. package/dist/templates/real-estate/package.json +38 -0
  184. package/dist/templates/real-estate/protobase.config.ts +5 -0
  185. package/dist/templates/real-estate/protobase.ui.tsx +16 -0
  186. package/dist/templates/real-estate/seed/base-organization.test.ts +17 -0
  187. package/dist/templates/real-estate/seed/base-organization.ts +10 -0
  188. package/dist/templates/real-estate/seed/base.ts +18 -0
  189. package/dist/templates/real-estate/seed/builders/allocation.ts +47 -0
  190. package/dist/templates/real-estate/seed/builders/ledger.ts +52 -0
  191. package/dist/templates/real-estate/seed/builders/property.ts +98 -0
  192. package/dist/templates/real-estate/seed/builders/tenancy.ts +99 -0
  193. package/dist/templates/real-estate/seed/builders/ticket.ts +115 -0
  194. package/dist/templates/real-estate/seed/calendar.ts +77 -0
  195. package/dist/templates/real-estate/seed/cli.ts +50 -0
  196. package/dist/templates/real-estate/seed/copy.ts +22 -0
  197. package/dist/templates/real-estate/seed/data/names.ts +31 -0
  198. package/dist/templates/real-estate/seed/data/reference.ts +63 -0
  199. package/dist/templates/real-estate/seed/format.ts +24 -0
  200. package/dist/templates/real-estate/seed/iban.ts +15 -0
  201. package/dist/templates/real-estate/seed/maintenance.ts +63 -0
  202. package/dist/templates/real-estate/seed/rng.ts +36 -0
  203. package/dist/templates/real-estate/seed/scales.ts +9 -0
  204. package/dist/templates/real-estate/seed/steps/billing.ts +44 -0
  205. package/dist/templates/real-estate/seed/steps/leasing.ts +60 -0
  206. package/dist/templates/real-estate/seed/steps/maintenance.ts +77 -0
  207. package/dist/templates/real-estate/seed/steps/portfolio.ts +97 -0
  208. package/dist/templates/real-estate/seed/steps/reference.ts +21 -0
  209. package/dist/templates/real-estate/seed/world.ts +92 -0
  210. package/dist/templates/real-estate/tsconfig.json +16 -0
  211. package/dist/templates/real-estate/vitest.config.ts +4 -0
  212. package/dist/templates/scratch/_.gitignore +3 -0
  213. package/dist/templates/scratch/_bunfig.toml +4 -0
  214. package/dist/templates/scratch/db/migrate.ts +30 -0
  215. package/dist/templates/scratch/db/migrations/001_auth.sql +36 -0
  216. package/dist/templates/scratch/db/migrations/002_auth_snake_case.sql +109 -0
  217. package/dist/templates/scratch/db/migrations/003_auth.sql +6 -0
  218. package/dist/templates/scratch/db/migrations/004_auth.sql +4 -0
  219. package/dist/templates/scratch/node-types.d.ts +1 -0
  220. package/dist/{template → templates/scratch}/package.json +5 -5
  221. package/dist/templates/scratch/tsconfig.json +16 -0
  222. package/package.json +2 -2
  223. /package/dist/{template → templates/erp}/_.gitignore +0 -0
  224. /package/dist/{template → templates/erp}/_bunfig.toml +0 -0
  225. /package/dist/{template → templates/erp}/db/migrate.ts +0 -0
  226. /package/dist/{template/db/migrations/001_auth.sql → templates/erp/db/migrations/009_auth.sql} +0 -0
  227. /package/dist/{template/db/migrations/002_auth_snake_case.sql → templates/erp/db/migrations/010_auth_snake_case.sql} +0 -0
  228. /package/dist/{template/db/migrations/003_auth.sql → templates/erp/db/migrations/014_auth.sql} +0 -0
  229. /package/dist/{template/db/migrations/004_auth.sql → templates/erp/db/migrations/015_auth.sql} +0 -0
  230. /package/dist/{template → templates/erp}/node-types.d.ts +0 -0
  231. /package/dist/{template → templates/erp}/tsconfig.json +0 -0
  232. /package/dist/{template → templates/scratch}/.env.example +0 -0
  233. /package/dist/{template → templates/scratch}/AGENTS.md +0 -0
  234. /package/dist/{template → templates/scratch}/auth/auth.ts +0 -0
  235. /package/dist/{template → templates/scratch}/config/index.ts +0 -0
  236. /package/dist/{template → templates/scratch}/config/user-menu.ts +0 -0
  237. /package/dist/{template → templates/scratch}/db/connection.ts +0 -0
  238. /package/dist/{template → templates/scratch}/db/migrations/.gitkeep +0 -0
  239. /package/dist/{template → templates/scratch}/protobase.config.ts +0 -0
  240. /package/dist/{template → templates/scratch}/vitest.config.ts +0 -0
@@ -0,0 +1,109 @@
1
+ -- The auth schema changes this version of Protobase needs, written by `protobase auth migration`.
2
+ -- Applied with the project's other migrations; edit or remove statements the project handles differently.
3
+
4
+ -- Renames the auth store of Protobase 0.6 to snake_case. A store set up with the snake_case names has no "emailVerified"
5
+ -- and is left as it is.
6
+ do $$
7
+ declare
8
+ constraint_row record;
9
+ begin
10
+ if not exists (select from information_schema.columns where table_schema = 'auth' and table_name = 'user' and column_name = 'emailVerified') then
11
+ return;
12
+ end if;
13
+
14
+ alter table "auth"."twoFactor" rename to two_factor;
15
+ alter table "auth"."signInPolicy" rename to sign_in_policy;
16
+ alter table "auth"."staffSignIn" rename to staff_sign_in;
17
+
18
+ alter table "auth"."user" rename column "emailVerified" to email_verified;
19
+ alter table "auth"."user" rename column "createdAt" to created_at;
20
+ alter table "auth"."user" rename column "updatedAt" to updated_at;
21
+ alter table "auth"."user" rename column "banReason" to ban_reason;
22
+ alter table "auth"."user" rename column "banExpires" to ban_expires;
23
+ alter table "auth"."user" rename column "twoFactorEnabled" to two_factor_enabled;
24
+
25
+ alter table "auth".session rename column "expiresAt" to expires_at;
26
+ alter table "auth".session rename column "createdAt" to created_at;
27
+ alter table "auth".session rename column "updatedAt" to updated_at;
28
+ alter table "auth".session rename column "ipAddress" to ip_address;
29
+ alter table "auth".session rename column "userAgent" to user_agent;
30
+ alter table "auth".session rename column "userId" to user_id;
31
+ alter table "auth".session rename column "impersonatedBy" to impersonated_by;
32
+
33
+ alter table "auth".account rename column "accountId" to account_id;
34
+ alter table "auth".account rename column "providerId" to provider_id;
35
+ alter table "auth".account rename column "userId" to user_id;
36
+ alter table "auth".account rename column "accessToken" to access_token;
37
+ alter table "auth".account rename column "refreshToken" to refresh_token;
38
+ alter table "auth".account rename column "idToken" to id_token;
39
+ alter table "auth".account rename column "accessTokenExpiresAt" to access_token_expires_at;
40
+ alter table "auth".account rename column "refreshTokenExpiresAt" to refresh_token_expires_at;
41
+ alter table "auth".account rename column "createdAt" to created_at;
42
+ alter table "auth".account rename column "updatedAt" to updated_at;
43
+
44
+ alter table "auth".verification rename column "expiresAt" to expires_at;
45
+ alter table "auth".verification rename column "createdAt" to created_at;
46
+ alter table "auth".verification rename column "updatedAt" to updated_at;
47
+
48
+ alter table "auth".jwks rename column "publicKey" to public_key;
49
+ alter table "auth".jwks rename column "privateKey" to private_key;
50
+ alter table "auth".jwks rename column "createdAt" to created_at;
51
+ alter table "auth".jwks rename column "expiresAt" to expires_at;
52
+
53
+ alter table "auth".two_factor rename column "backupCodes" to backup_codes;
54
+ alter table "auth".two_factor rename column "userId" to user_id;
55
+ alter table "auth".two_factor rename column "failedVerificationCount" to failed_verification_count;
56
+ alter table "auth".two_factor rename column "lockedUntil" to locked_until;
57
+
58
+ alter table "auth".passkey rename column "publicKey" to public_key;
59
+ alter table "auth".passkey rename column "userId" to user_id;
60
+ alter table "auth".passkey rename column "credentialID" to credential_id;
61
+ alter table "auth".passkey rename column "deviceType" to device_type;
62
+ alter table "auth".passkey rename column "backedUp" to backed_up;
63
+ alter table "auth".passkey rename column "createdAt" to created_at;
64
+
65
+ alter table "auth".sign_in_policy rename column "emailCode" to email_code;
66
+ alter table "auth".sign_in_policy rename column "twoFactor" to two_factor;
67
+ alter table "auth".sign_in_policy rename column "staffAccess" to staff_access;
68
+ alter table "auth".sign_in_policy rename column "platformSignIn" to platform_sign_in;
69
+ alter table "auth".sign_in_policy rename column "createdAt" to created_at;
70
+ alter table "auth".sign_in_policy rename column "createdBy" to created_by;
71
+
72
+ alter table "auth".staff_sign_in rename column "userId" to user_id;
73
+ alter table "auth".staff_sign_in rename column "userEmail" to user_email;
74
+ alter table "auth".staff_sign_in rename column "staffName" to staff_name;
75
+ alter table "auth".staff_sign_in rename column "staffSubject" to staff_subject;
76
+ alter table "auth".staff_sign_in rename column "sessionId" to session_id;
77
+ alter table "auth".staff_sign_in rename column "startedAt" to started_at;
78
+ alter table "auth".staff_sign_in rename column "expiresAt" to expires_at;
79
+ alter table "auth".staff_sign_in rename column "endedAt" to ended_at;
80
+
81
+ -- A primary key's index takes the new name with it.
82
+ alter table "auth".two_factor rename constraint "twoFactor_pkey" to two_factor_pkey;
83
+ alter table "auth".sign_in_policy rename constraint "signInPolicy_pkey" to sign_in_policy_pkey;
84
+ alter table "auth".staff_sign_in rename constraint "staffSignIn_pkey" to staff_sign_in_pkey;
85
+ alter table "auth".session rename constraint "session_userId_fkey" to session_user_id_fkey;
86
+ alter table "auth".account rename constraint "account_userId_fkey" to account_user_id_fkey;
87
+ alter table "auth".two_factor rename constraint "twoFactor_userId_fkey" to two_factor_user_id_fkey;
88
+ alter table "auth".passkey rename constraint "passkey_userId_fkey" to passkey_user_id_fkey;
89
+
90
+ alter index "auth"."session_userId_idx" rename to session_user_id_idx;
91
+ alter index "auth"."account_userId_idx" rename to account_user_id_idx;
92
+ alter index "auth"."twoFactor_secret_idx" rename to two_factor_secret_idx;
93
+ alter index "auth"."twoFactor_userId_idx" rename to two_factor_user_id_idx;
94
+ alter index "auth"."passkey_userId_idx" rename to passkey_user_id_idx;
95
+ alter index "auth"."passkey_credentialID_idx" rename to passkey_credential_id_idx;
96
+
97
+ -- Postgres 18 names each not-null constraint after its table and column, as "user_emailVerified_not_null"; earlier
98
+ -- versions keep no such constraints, so there is nothing to rename there.
99
+ for constraint_row in
100
+ select c.conrelid::regclass as table_name, c.conname as old_name, t.relname || '_' || a.attname || '_not_null' as new_name
101
+ from pg_constraint c
102
+ join pg_class t on t.oid = c.conrelid
103
+ join pg_attribute a on a.attrelid = c.conrelid and a.attnum = c.conkey[1]
104
+ where c.contype = 'n' and t.relnamespace = (select oid from pg_namespace where nspname = 'auth') and c.conname ~ '[A-Z]'
105
+ loop
106
+ execute format('alter table %s rename constraint %I to %I', constraint_row.table_name, constraint_row.old_name, constraint_row.new_name);
107
+ end loop;
108
+ end
109
+ $$;
@@ -0,0 +1,6 @@
1
+ -- The auth schema changes this version of Protobase needs, written by `protobase auth migration`.
2
+ -- Applied with the project's other migrations; edit or remove statements the project handles differently.
3
+
4
+ alter table "auth"."user" add column if not exists "last_role" text;
5
+
6
+ alter table "auth"."session" add column if not exists "active_role" text;
@@ -0,0 +1,4 @@
1
+ -- The auth schema changes this version of Protobase needs, written by `protobase migration`.
2
+ -- Applied with the project's other migrations; edit or remove statements the project handles differently.
3
+
4
+ alter table "auth"."passkey" add column if not exists "rp_id" text;
@@ -0,0 +1,8 @@
1
+ -- A filter, sort or search reads only columns an index serves.
2
+ create index inspections_condition_idx on maintenance.inspections (organization_id, condition);
3
+ create index lease_tenants_role_idx on leasing.lease_tenants (organization_id, role);
4
+ create index leases_term_idx on leasing.leases (organization_id, term);
5
+ create index rent_charges_outstanding_idx on billing.rent_charges (outstanding);
6
+ create index tenants_first_name_trgm_idx on leasing.tenants using gin (first_name gin_trgm_ops);
7
+ create index tenants_phone_digits_idx on leasing.tenants using gin ((regexp_replace(phone, '[^0-9]', '', 'g')) gin_trgm_ops);
8
+ create index tickets_number_trgm_idx on maintenance.tickets using gin (number gin_trgm_ops);
@@ -0,0 +1,2 @@
1
+ -- Each sortable column is served alone too, as a list sorted by it is; the tenant is pinned, so it leads.
2
+ create index tenants_last_name_idx on leasing.tenants (organization_id, last_name);
@@ -0,0 +1,17 @@
1
+ -- Everyone who signs in is one of the example's users: their Better Auth id is the next core.users id
2
+ -- (`generateUserId` in auth/auth.ts), which tickets name as their assignee, and their row there, in organization 1
3
+ -- where everyone works, comes with their account. The roles they hold in the app live in the auth schema, so the
4
+ -- example's own role column stays empty for them.
5
+ alter table core.users alter column role drop not null;
6
+
7
+ create function core.add_auth_user() returns trigger language plpgsql as $$
8
+ begin
9
+ insert into core.users (id, organization_id, email, name)
10
+ select new.id::integer, 1, new.email, new.name
11
+ where new.id ~ '^[0-9]+$'
12
+ on conflict do nothing;
13
+ return new;
14
+ end
15
+ $$;
16
+
17
+ create trigger auth_user after insert on auth."user" for each row execute function core.add_auth_user();
@@ -0,0 +1 @@
1
+ /// <reference types="node" />
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "real-estate",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "db:migrate": "bun db/migrate.ts",
8
+ "db:seed:base": "bun seed/base.ts",
9
+ "db:seed": "bun seed/cli.ts",
10
+ "db:reset": "bun db/drop.ts && bun db/migrate.ts && bun seed/cli.ts --scale small",
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "vitest run",
13
+ "protobase": "protobase",
14
+ "build": "protobase build",
15
+ "serve": "protobase build && protobase serve dist",
16
+ "dev": "protobase dev"
17
+ },
18
+ "dependencies": {
19
+ "@protobase/cli": ">=0.24.0 <1",
20
+ "@protobase/layout": ">=0.24.0 <1",
21
+ "@protobase/schema": ">=0.24.0 <1",
22
+ "@protobase/server": ">=0.24.0 <1",
23
+ "@protobase/ui": ">=0.24.0 <1",
24
+ "better-auth": "^1.7.7",
25
+ "jose": "^6.2.12",
26
+ "kysely": "^0.29.6",
27
+ "pg": "^8.23.1",
28
+ "postgres": "^3.4.9",
29
+ "react": "^19.3.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.20.5",
33
+ "@types/pg": "^8.23.1",
34
+ "@types/react": "^19.3.0",
35
+ "typescript": "^6.0.3",
36
+ "vitest": "^5.0.3"
37
+ }
38
+ }
@@ -0,0 +1,5 @@
1
+ import * as config from './config'
2
+ import { roles } from './config/roles'
3
+ import { auth, authenticate } from './auth/auth'
4
+
5
+ export default { config, auth, authenticate, options: { roles } }
@@ -0,0 +1,16 @@
1
+ import { defineUi, type ActionContext } from '@protobase/ui'
2
+
3
+ /** "Send reminder" on an arrears row: opens a mail to the lease's primary tenant about what is outstanding, with the note it asked for. */
4
+ const remind = async ({ record, client, values }: ActionContext) => {
5
+ const leaseId = String(record!.lease_id)
6
+ const signers = await client.list('lease_tenants', { filter: `lease_id = ${leaseId} AND role = "primary"`, pageSize: 1 })
7
+ const tenantId = signers.items[0]?.tenant_id
8
+ const tenant = tenantId === undefined ? undefined : await client.get('tenants', String(tenantId))
9
+ const lease = await client.get('leases', leaseId)
10
+ const subject = encodeURIComponent(`Rent outstanding on lease ${String(lease.record.number)}`)
11
+ const outstanding = `€${String(record!.balance)} is outstanding, the oldest since ${String(record!.oldest_due_on)}.`
12
+ const body = encodeURIComponent(values?.note ? `${outstanding}\n\n${String(values.note)}` : outstanding)
13
+ window.location.assign(`mailto:${String(tenant?.record.email ?? '')}?subject=${subject}&body=${body}`)
14
+ }
15
+
16
+ export default defineUi({ actions: { remind } })
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { baseOrganization } from './base-organization'
3
+
4
+ describe('baseOrganization', () => {
5
+ it('is organization 1, named after the app, with its city and KvK number left for the owner', () => {
6
+ expect(baseOrganization('Acme Rentals')).toEqual({ id: 1, name: 'Acme Rentals', slug: 'acme-rentals', city: '', kvk_number: '' })
7
+ })
8
+
9
+ it('makes a slug of any name', () => {
10
+ expect(baseOrganization('Grachtenhof Vastgoed B.V.').slug).toBe('grachtenhof-vastgoed-b-v')
11
+ expect(baseOrganization('!!!').slug).toBe('organization')
12
+ })
13
+
14
+ it('refuses an empty name', () => {
15
+ expect(() => baseOrganization(' ')).toThrow('name')
16
+ })
17
+ })
@@ -0,0 +1,10 @@
1
+ // Lowercase ASCII words joined by dashes; diacritics are dropped, so Müller becomes muller.
2
+ const slugOf = (name: string) =>
3
+ name.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'organization'
4
+
5
+ /** Organization 1, the one every user works in (auth/auth.ts), named after the app; the owner fills in the rest. */
6
+ export const baseOrganization = (name: string) => {
7
+ const trimmed = name.trim()
8
+ if (!trimmed) throw new Error('The organization needs a name: pass --name "<the app name>"')
9
+ return { id: 1, name: trimmed, slug: slugOf(trimmed), city: '', kvk_number: '' }
10
+ }
@@ -0,0 +1,18 @@
1
+ import { connect } from '../db/connection'
2
+ import { baseOrganization } from './base-organization'
3
+ import { amenities } from './data/reference'
4
+
5
+ // `db:seed:base [--name <app name>]`: what a new app starts with, the amenities and organization 1. Rows already
6
+ // there stay as they are, so it can run again and after the sample seed (`db:seed`).
7
+ const flag = process.argv.indexOf('--name')
8
+ const organization = baseOrganization(flag === -1 ? 'Real estate' : (process.argv[flag + 1] ?? ''))
9
+
10
+ const sql = connect({ max: 1 })
11
+ await sql.begin(async (tx) => {
12
+ await tx`insert into portfolio.amenities ${tx(amenities.map(([code, name]) => ({ code, name })))} on conflict do nothing`
13
+ await tx`insert into core.organizations ${tx(organization)} on conflict do nothing`
14
+ // An explicit id leaves the identity sequence behind, so the next organization would take id 1 again.
15
+ await tx`select setval(pg_get_serial_sequence('core.organizations', 'id'), (select max(id) from core.organizations))`
16
+ })
17
+ await sql.end()
18
+ console.log(`seeded the base data of ${organization.name}`)
@@ -0,0 +1,47 @@
1
+ import { DAY, addMonths, firstPeriod, monthOf, windowEnd, windowStart } from '../calendar'
2
+ import { int, pick, rngAt, weighted } from '../rng'
3
+
4
+ // What the ids of everything under a property depend on: world.ts counts units, leases and tenants with these before
5
+ // any row is built, so they take only the organization's seed and an ordinal.
6
+
7
+ const YEAR = 365 * DAY
8
+
9
+ export const propertyShapeAt = (seed: number, ordinal: number) => {
10
+ const rand = rngAt(seed + 1, ordinal)
11
+ const kind = weighted(rand, [['single_family', 45], ['apartment_building', 40], ['mixed_use', 15]] as const)
12
+ const units = kind === 'single_family' ? 1 : kind === 'mixed_use' ? int(rand, 2, 12) : int(rand, 6, 40)
13
+ return { kind, units }
14
+ }
15
+
16
+ // Most of the portfolio was under management before the window; about one property in eight joined during it.
17
+ export const propertyAcquiredAt = (seed: number, ordinal: number) => {
18
+ const rand = rngAt(seed + 2, ordinal)
19
+ if (rand() < 0.88) return Math.floor(windowStart - rand() * 8 * YEAR)
20
+ return Math.floor(windowStart + rand() * (windowEnd - windowStart - 120 * DAY))
21
+ }
22
+
23
+ export type Tenancy = { start: number; end: number | null; term: 'fixed' | 'indefinite'; gapAfter: number }
24
+
25
+ // The leases of one unit, oldest first, as months: each starts on a first and ends on a last day of a month, and the
26
+ // next starts after a few empty months at most (the longer gaps are renovations). Only leases that reach into the
27
+ // window are kept. An indefinite lease that runs past the window has no end date yet; a fixed one always has one.
28
+ // `unit` is the organization-wide unit ordinal.
29
+ export const tenanciesOf = (seed: number, unit: number, availableFrom: number) => {
30
+ const rand = rngAt(seed + 6, unit)
31
+ const tenancies: Tenancy[] = []
32
+ let start = availableFrom < windowStart ? addMonths(firstPeriod, -int(rand, 1, 96)) : addMonths(monthOf(availableFrom), int(rand, 1, 3))
33
+ while (start <= windowEnd) {
34
+ const term = rand() < 0.35 ? 'fixed' : 'indefinite'
35
+ const months = term === 'fixed' ? pick(rand, [12, 24]) : weighted(rand, [[int(rand, 12, 36), 35], [int(rand, 37, 96), 45], [int(rand, 97, 240), 20]] as const)
36
+ const next = addMonths(start, months)
37
+ const lastDay = next - DAY
38
+ const end = term === 'fixed' || lastDay <= windowEnd ? lastDay : null
39
+ const gapAfter = weighted(rand, [[0, 55], [1, 25], [2, 12], [int(rand, 3, 5), 8]] as const)
40
+ if (lastDay >= windowStart) tenancies.push({ start, end, term, gapAfter })
41
+ start = addMonths(next, gapAfter)
42
+ }
43
+ return tenancies
44
+ }
45
+
46
+ export const coSigned = (seed: number, lease: number) => rngAt(seed + 7, lease)() < 0.3
47
+
@@ -0,0 +1,52 @@
1
+ import { DAY, addMonths, firstPeriod, lastPeriod, monthOf, windowEnd } from '../calendar'
2
+ import { int, pick, rngAt, weighted } from '../rng'
3
+ import type { Org } from '../world'
4
+ import { rentAt, type SeededLease } from './tenancy'
5
+
6
+ const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
7
+
8
+ type Payment = { paidOn: number; amountCents: number; method: 'direct_debit' | 'bank_transfer' | 'ideal' }
9
+
10
+ // How one charge of `amount` due on `due` gets paid, by the lease's kind of payer. Payments after the window's end
11
+ // have not happened yet. The parts of a charge never add up to more than the charge.
12
+ const paymentsFor = (payer: SeededLease['payer'], due: number, amount: number, stopped: boolean, rand: () => number): Payment[] => {
13
+ const pay = (days: number, cents: number, method: Payment['method']) => ({ paidOn: due + days * DAY, amountCents: cents, method })
14
+ const transfer = () => pick(rand, ['bank_transfer', 'bank_transfer', 'ideal'] as const)
15
+ if (stopped) return []
16
+ if (payer === 'punctual') return [pay(int(rand, 0, 3), amount, rand() < 0.8 ? 'direct_debit' : 'bank_transfer')]
17
+ if (payer === 'late' || payer === 'defaulter') return [pay(int(rand, 3, 28), amount, transfer())]
18
+ const kind = weighted(rand, [['late', 60], ['split', 32], ['short', 8]] as const)
19
+ if (kind === 'late') return [pay(int(rand, 10, 60), amount, transfer())]
20
+ const part = Math.round(amount * (0.3 + rand() * 0.5))
21
+ if (kind === 'split') return [pay(int(rand, 5, 30), part, transfer()), pay(int(rand, 31, 90), amount - part, transfer())]
22
+ return [pay(int(rand, 10, 60), part, transfer())]
23
+ }
24
+
25
+ /**
26
+ * The monthly charges of a lease inside the window, each with the payments made on it by the window's end. A
27
+ * defaulter stops paying for the last two to seven months of the lease (or of the window, while it runs).
28
+ */
29
+ export const ledgerOf = (org: Org, lease: SeededLease) => {
30
+ const rand = rngAt(org.seed + 12, lease.ordinal)
31
+ const { start, end } = lease.tenancy
32
+ const from = Math.max(firstPeriod, start)
33
+ const to = end === null ? lastPeriod : Math.min(lastPeriod, monthOf(end))
34
+ const stopFrom = lease.payer === 'defaulter' ? addMonths(to, -int(rand, 1, 6)) : Infinity
35
+ const charges = []
36
+ for (let period = from; period <= to; period = addMonths(period, 1)) {
37
+ const date = new Date(period)
38
+ const amountCents = rentAt(lease.startCents, start, period) + lease.serviceCents
39
+ const payments = paymentsFor(lease.payer, period, amountCents, period >= stopFrom, rand).filter((payment) => payment.paidOn <= windowEnd)
40
+ charges.push({
41
+ period,
42
+ dueOn: period,
43
+ description: `Rent ${monthNames[date.getUTCMonth()]} ${date.getUTCFullYear()}`,
44
+ amountCents,
45
+ paidCents: payments.reduce((sum, payment) => sum + payment.amountCents, 0),
46
+ payments,
47
+ // The monthly run bills a week ahead.
48
+ createdAt: Math.min(period - 7 * DAY + 6 * 3_600_000, windowEnd),
49
+ })
50
+ }
51
+ return charges
52
+ }
@@ -0,0 +1,98 @@
1
+ import { DAY, windowStart } from '../calendar'
2
+ import { buildingNames, buildingPrefixes, firstNames, ownerCompanyWords, streets, surnames } from '../data/names'
3
+ import { iban } from '../iban'
4
+ import { int, pick, rngAt, weighted } from '../rng'
5
+ import type { Org } from '../world'
6
+ import { propertyAcquiredAt, propertyShapeAt } from './allocation'
7
+
8
+ const YEAR = 365 * DAY
9
+ const letters = 'ABCDEFGHJKLMNPRTVWXZ'
10
+
11
+ export const ownerAt = (org: Org, ordinal: number) => {
12
+ const rand = rngAt(org.seed + 3, ordinal)
13
+ const kind = weighted(rand, [['private', 55], ['company', 25], ['pension_fund', 8], ['housing_association', 12]] as const)
14
+ const surname = pick(rand, surnames)
15
+ const name =
16
+ kind === 'private' ? `${pick(rand, firstNames)} ${surname}`
17
+ : kind === 'company' ? `${surname.replace(/^(van|de|der|den|el)\s+/i, '').replace(/^\w/, (c) => c.toUpperCase())} ${pick(rand, ownerCompanyWords)} B.V.`
18
+ : kind === 'pension_fund' ? `Stichting Pensioenfonds ${pick(rand, buildingNames)}`
19
+ : `Woonstichting ${pick(rand, buildingNames)}`
20
+ const domain = kind === 'private' ? pick(rand, ['gmail.com', 'outlook.com', 'ziggo.nl', 'kpnmail.nl']) : `${name.toLowerCase().replace(/ b\.v\.$/, '').replace(/[^a-z]+/g, '')}.nl`
21
+ const fee = kind === 'private' ? int(rand, 700, 900) : kind === 'company' ? int(rand, 500, 650) : int(rand, 350, 500)
22
+ return {
23
+ id: org.ownerFirstId + ordinal,
24
+ name,
25
+ kind,
26
+ email: kind === 'private' ? `${name.split(' ')[0]!.toLowerCase()}.${surname.toLowerCase().replace(/[^a-z]+/g, '')}@${domain}` : `beheer@${domain}`,
27
+ phone: rand() < 0.8 ? `+31 ${int(rand, 10, 79)} ${int(rand, 1_000_000, 9_999_999)}` : null,
28
+ iban: iban(rand),
29
+ feePercent: fee / 100,
30
+ createdAt: Math.floor(windowStart - int(rand, 1, 9) * YEAR),
31
+ }
32
+ }
33
+
34
+ const builtYear = (rand: () => number) =>
35
+ weighted(rand, [[int(rand, 1890, 1940), 25], [int(rand, 1946, 1979), 30], [int(rand, 1980, 2005), 30], [int(rand, 2006, 2024), 15]] as const)
36
+
37
+ export const propertyAt = (org: Org, ordinal: number) => {
38
+ const { kind, units } = propertyShapeAt(org.seed, ordinal)
39
+ const rand = rngAt(org.seed + 4, ordinal)
40
+ const [city, lat, lng, postalFrom, postalTo, rentPerM2] = weighted(rand, org.cities.map((entry, index) => [entry, index === 0 ? 40 : 12] as const))
41
+ const street = pick(rand, streets)
42
+ const number = int(rand, 1, 240)
43
+ const houseNumber = kind === 'single_family' && rand() < 0.1 ? `${number}${pick(rand, ['a', 'b'])}` : String(number)
44
+ const building = `${pick(rand, buildingPrefixes)} ${pick(rand, buildingNames)}`
45
+ return {
46
+ id: org.propertyFirstId + ordinal,
47
+ ownerId: org.ownerFirstId + Math.floor(rand() ** 1.7 * org.ownerCount),
48
+ kind,
49
+ units,
50
+ name: kind === 'single_family' ? `${street} ${houseNumber}` : building,
51
+ shortName: kind === 'single_family' ? `${street} ${houseNumber}` : building.split(' ').slice(1).join(' '),
52
+ street,
53
+ houseNumber,
54
+ postalCode: `${int(rand, postalFrom, postalTo)} ${pick(rand, [...letters])}${pick(rand, [...letters])}`,
55
+ city,
56
+ latitude: (lat + (rand() - 0.5) * 0.06).toFixed(6),
57
+ longitude: (lng + (rand() - 0.5) * 0.09).toFixed(6),
58
+ rentPerM2,
59
+ builtYear: builtYear(rand),
60
+ createdAt: propertyAcquiredAt(org.seed, ordinal),
61
+ }
62
+ }
63
+
64
+ export type SeededProperty = ReturnType<typeof propertyAt>
65
+
66
+ const energyLabels = ['A++++', 'A+++', 'A++', 'A+', 'A', 'B', 'C', 'D', 'E', 'F', 'G'] as const
67
+
68
+ // Newer buildings and renovated older ones carry better labels.
69
+ const energyLabel = (builtYear: number, rand: () => number) => {
70
+ const base = builtYear >= 2015 ? 2 : builtYear >= 2000 ? 4 : builtYear >= 1980 ? 6 : 7
71
+ return energyLabels[Math.max(0, Math.min(energyLabels.length - 1, base + int(rand, -2, 2)))]!
72
+ }
73
+
74
+ // The unit's slot inside its property; `unit` is the organization-wide unit ordinal.
75
+ export const unitAt = (org: Org, property: SeededProperty, unit: number) => {
76
+ const slot = unit - org.unitStarts[property.id - org.propertyFirstId]!
77
+ const rand = rngAt(org.seed + 5, unit)
78
+ const perFloor = property.kind === 'mixed_use' ? 3 : 4
79
+ // Mixed-use blocks have shops on the ground floor, so their homes start on the first.
80
+ const floor = property.kind === 'single_family' ? 0 : Math.floor(slot / perFloor) + (property.kind === 'mixed_use' ? 1 : 0)
81
+ const area = property.kind === 'single_family' ? int(rand, 85, 165) : int(rand, 38, 115)
82
+ const bedrooms = area < 45 ? int(rand, 0, 1) : area < 70 ? 2 : area < 100 ? 3 : 4
83
+ const marketFactor = 0.9 + rand() * 0.25
84
+ return {
85
+ id: org.unitFirstId + unit,
86
+ label: property.kind === 'single_family' ? property.shortName : `${property.shortName} ${floor}.${String((slot % perFloor) + 1).padStart(2, '0')}`,
87
+ floor,
88
+ bedrooms,
89
+ area,
90
+ energyLabel: energyLabel(property.builtYear, rand),
91
+ // 2026 market rent, in whole euros.
92
+ baseRentCents: Math.round(area * property.rentPerM2 * marketFactor * (property.kind === 'single_family' ? 0.92 : 1)) * 100,
93
+ topFloor: property.kind !== 'single_family' && slot >= property.units - perFloor,
94
+ createdAt: property.createdAt,
95
+ }
96
+ }
97
+
98
+ export type SeededUnit = ReturnType<typeof unitAt>
@@ -0,0 +1,99 @@
1
+ import { DAY, addMonths, lastPeriod, monthOf, windowEnd } from '../calendar'
2
+ import { firstNames, surnames } from '../data/names'
3
+ import { slugify } from '../format'
4
+ import { iban } from '../iban'
5
+ import { int, pick, rngAt, weighted } from '../rng'
6
+ import type { Org } from '../world'
7
+ import type { Tenancy } from './allocation'
8
+ import type { SeededProperty, SeededUnit } from './property'
9
+
10
+ const YEAR = 365 * DAY
11
+
12
+ /** Let when a lease covers the window's last day, else vacant, or under renovation in a long gap. */
13
+ export const unitStatus = (tenancies: Tenancy[]) => {
14
+ const today = Math.floor(windowEnd / DAY) * DAY
15
+ if (tenancies.some((tenancy) => tenancy.start <= today && (tenancy.end === null || tenancy.end >= today))) return 'let'
16
+ const before = tenancies.filter((tenancy) => tenancy.start <= today).at(-1)
17
+ return before && before.gapAfter >= 3 ? 'renovation' : 'vacant'
18
+ }
19
+
20
+ // The yearly rent increase on 1 July, in the range Dutch free-sector leases allowed.
21
+ const increases: Record<number, number> = { 2016: 0.025, 2017: 0.025, 2018: 0.03, 2019: 0.03, 2020: 0.026, 2021: 0, 2022: 0.033, 2023: 0.055, 2024: 0.055, 2025: 0.041, 2026: 0.044 }
22
+
23
+ /** The rent in cents a lease starting at `startCents` charges for `period` (a month), after every July increase since. */
24
+ export const rentAt = (startCents: number, start: number, period: number) => {
25
+ let rent = startCents
26
+ for (let year = new Date(start).getUTCFullYear(); year <= new Date(period).getUTCFullYear(); year++) {
27
+ const july = Date.UTC(year, 6, 1)
28
+ if (july > start && july <= period) rent = Math.round(rent * (1 + (increases[year] ?? 0.025)))
29
+ }
30
+ return rent
31
+ }
32
+
33
+ // `lease` is the organization-wide lease ordinal, `index` the lease's position among its unit's leases.
34
+ export const leaseAt = (org: Org, property: SeededProperty, unit: SeededUnit, unitOrdinal: number, index: number, tenancy: Tenancy) => {
35
+ const lease = org.leaseStarts[unitOrdinal]! + index
36
+ const rand = rngAt(org.seed + 8, lease)
37
+ // New leases follow the market: the 2026 rent, about 3.5% less for every year before.
38
+ const startCents = Math.round((unit.baseRentCents * 0.965 ** ((lastPeriod - tenancy.start) / YEAR)) / 100) * 100
39
+ const lastMonth = tenancy.end === null ? lastPeriod : Math.min(lastPeriod, monthOf(tenancy.end))
40
+ const serviceCents = property.kind === 'single_family' ? 0 : int(rand, 9, 24) * 500
41
+ const signedAt = tenancy.start - int(rand, 14, 50) * DAY - int(rand, 9, 17) * 3_600_000
42
+ const notice = tenancy.term === 'indefinite' && tenancy.end !== null ? addMonths(monthOf(tenancy.end), -int(rand, 1, 3)) + int(rand, 0, 20) * DAY : null
43
+ return {
44
+ id: org.leaseFirstId + lease,
45
+ ordinal: lease,
46
+ number: `HC-${new Date(tenancy.start).getUTCFullYear()}-${String(lease + 1).padStart(6, '0')}`,
47
+ unitId: unit.id,
48
+ tenancy,
49
+ startCents,
50
+ rentCents: rentAt(startCents, tenancy.start, lastMonth),
51
+ serviceCents,
52
+ notice,
53
+ // Late payers are few: the arrears view should have something to show, not everything.
54
+ payer: weighted(rand, [['punctual', 74], ['late', 17], ['chronic', 6], ['defaulter', 3]] as const),
55
+ createdAt: signedAt,
56
+ updatedAt: notice ?? signedAt,
57
+ }
58
+ }
59
+
60
+ export type SeededLease = ReturnType<typeof leaseAt>
61
+
62
+ const providers = ['gmail.com', 'outlook.com', 'hotmail.com', 'ziggo.nl', 'kpnmail.nl', 'icloud.com', 'live.nl']
63
+
64
+ // The tenants of a lease: the primary tenant first, then a co-signer when there is one.
65
+ export const tenantsOf = (org: Org, lease: SeededLease) => {
66
+ const first = org.tenantStarts[lease.ordinal]!
67
+ const count = org.tenantStarts[lease.ordinal + 1]! - first
68
+ return Array.from({ length: count }, (_, slot) => {
69
+ const rand = rngAt(org.seed + 9, first + slot)
70
+ const firstName = pick(rand, firstNames)
71
+ const lastName = pick(rand, surnames)
72
+ const age = weighted(rand, [[int(rand, 20, 29), 40], [int(rand, 30, 44), 35], [int(rand, 45, 64), 18], [int(rand, 65, 80), 7]] as const)
73
+ return {
74
+ id: org.tenantFirstId + first + slot,
75
+ role: slot === 0 ? 'primary' : 'co_signer',
76
+ firstName,
77
+ lastName,
78
+ email: `${slugify(firstName)}.${slugify(lastName)}${rand() < 0.4 ? int(rand, 1, 99) : ''}@${pick(rand, providers)}`,
79
+ phone: rand() < 0.9 ? `+31 6 ${int(rand, 10_000_000, 99_999_999)}` : null,
80
+ dateOfBirth: lease.tenancy.start - age * YEAR - int(rand, 0, 364) * DAY,
81
+ iban: iban(rand),
82
+ // Landlords ask for an income of about four times the rent; a co-signer adds to it.
83
+ incomeCents: Math.round((lease.startCents * (slot === 0 ? 3 + rand() * 2.5 : 1 + rand() * 2)) / 100) * 100,
84
+ createdAt: lease.createdAt,
85
+ }
86
+ })
87
+ }
88
+
89
+ /** The deposit of a lease: two months of its first rent, returned (sometimes in part) after it ends. */
90
+ export const depositFor = (org: Org, lease: SeededLease) => {
91
+ const rand = rngAt(org.seed + 11, lease.ordinal)
92
+ const amountCents = lease.startCents * 2
93
+ const receivedOn = lease.createdAt + int(rand, 1, 7) * DAY
94
+ const returnedOn = lease.tenancy.end === null ? null : lease.tenancy.end + int(rand, 14, 42) * DAY
95
+ if (returnedOn === null || returnedOn > windowEnd) return { amountCents, receivedOn, status: 'held', returnedCents: 0, returnedOn: null }
96
+ const status = weighted(rand, [['returned', 85], ['partially_returned', 12], ['withheld', 3]] as const)
97
+ const returnedCents = status === 'returned' ? amountCents : status === 'withheld' ? 0 : Math.round((amountCents * (0.3 + rand() * 0.6)) / 100) * 100
98
+ return { amountCents, receivedOn, status, returnedCents, returnedOn }
99
+ }