morty 0.0.1 → 0.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 (134) hide show
  1. checksums.yaml +5 -5
  2. data/.github/dependabot.yml +12 -0
  3. data/.github/workflows/ci.yml +107 -0
  4. data/.gitignore +17 -3
  5. data/.rubocop.yml +20 -0
  6. data/Appraisals +24 -0
  7. data/Gemfile +28 -1
  8. data/LICENSE +21 -0
  9. data/README.md +37 -7
  10. data/Rakefile +37 -0
  11. data/app/models/morty/account.rb +37 -0
  12. data/app/models/morty/account_type.rb +7 -0
  13. data/app/models/morty/activity.rb +147 -0
  14. data/app/models/morty/activity_type.rb +7 -0
  15. data/app/models/morty/application_record.rb +6 -0
  16. data/app/models/morty/entry.rb +24 -0
  17. data/app/models/morty/entry_type.rb +23 -0
  18. data/app/models/morty/ledger.rb +8 -0
  19. data/config/routes.rb +2 -0
  20. data/config.ru +7 -0
  21. data/cucumber.yml +2 -0
  22. data/db/migrate/20260224063053_create_morty_schema.rb +17 -0
  23. data/db/seeds.rb +18 -0
  24. data/db/sql/create_morty_schema.sql +479 -0
  25. data/features/accountant.feature +47 -0
  26. data/features/adjustment.feature +79 -0
  27. data/features/cancel.feature +130 -0
  28. data/features/daily.feature +42 -0
  29. data/features/default.feature +33 -0
  30. data/features/ledger.feature +57 -0
  31. data/features/retroactive.feature +92 -0
  32. data/features/return.feature +112 -0
  33. data/features/reversal.feature +57 -0
  34. data/features/simulation.feature +128 -0
  35. data/features/support/accountants/adjusting_accountant.rb +34 -0
  36. data/features/support/accountants/daily_accountant.rb +13 -0
  37. data/features/support/accountants/default_accountant.rb +2 -0
  38. data/features/support/accountants/defaulting_accountant.rb +32 -0
  39. data/features/support/accountants/multiple_ledgers_accountant.rb +51 -0
  40. data/features/support/accountants/simulating_accountant.rb +36 -0
  41. data/features/support/accountants/sourceless_accountant.rb +2 -0
  42. data/features/support/accountants/waterfalling_accountant.rb +15 -0
  43. data/features/support/env.rb +17 -0
  44. data/features/waterfall.feature +34 -0
  45. data/gemfiles/rails_7.0.gemfile +30 -0
  46. data/gemfiles/rails_7.0.gemfile.lock +494 -0
  47. data/gemfiles/rails_7.1.gemfile +30 -0
  48. data/gemfiles/rails_7.1.gemfile.lock +543 -0
  49. data/gemfiles/rails_7.2.gemfile +30 -0
  50. data/gemfiles/rails_7.2.gemfile.lock +539 -0
  51. data/gemfiles/rails_8.0.gemfile +30 -0
  52. data/gemfiles/rails_8.0.gemfile.lock +536 -0
  53. data/gemfiles/rails_8.1.gemfile +30 -0
  54. data/gemfiles/rails_8.1.gemfile.lock +538 -0
  55. data/lib/morty/accountant.rb +332 -0
  56. data/lib/morty/adjustment.rb +64 -0
  57. data/lib/morty/book.rb +54 -0
  58. data/lib/morty/context/activity.rb +52 -0
  59. data/lib/morty/context/daily.rb +23 -0
  60. data/lib/morty/context/simulation.rb +26 -0
  61. data/lib/morty/cucumber/helpers.rb +27 -0
  62. data/lib/morty/cucumber/steps.rb +191 -0
  63. data/lib/morty/diff.rb +71 -0
  64. data/lib/morty/dsl.rb +86 -0
  65. data/lib/morty/engine.rb +21 -0
  66. data/lib/morty/error.rb +3 -0
  67. data/lib/morty/event.rb +27 -0
  68. data/lib/morty/list/activity.rb +57 -0
  69. data/lib/morty/rate.rb +59 -0
  70. data/lib/morty/schedule.rb +36 -0
  71. data/lib/morty/seed.rb +60 -0
  72. data/lib/morty/source.rb +19 -0
  73. data/lib/morty/tasks/morty_tasks.rake +4 -0
  74. data/lib/morty/version.rb +1 -1
  75. data/lib/morty.rb +27 -1
  76. data/morty.gemspec +22 -19
  77. data/spec/dummy/Rakefile +6 -0
  78. data/spec/dummy/app/assets/images/.keep +0 -0
  79. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  80. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  81. data/spec/dummy/app/controllers/concerns/.keep +0 -0
  82. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  83. data/spec/dummy/app/jobs/application_job.rb +7 -0
  84. data/spec/dummy/app/models/application_record.rb +3 -0
  85. data/spec/dummy/app/models/concerns/.keep +0 -0
  86. data/spec/dummy/app/views/layouts/application.html.erb +28 -0
  87. data/spec/dummy/app/views/pwa/manifest.json.erb +22 -0
  88. data/spec/dummy/app/views/pwa/service-worker.js +26 -0
  89. data/spec/dummy/bin/ci +6 -0
  90. data/spec/dummy/bin/dev +2 -0
  91. data/spec/dummy/bin/rails +4 -0
  92. data/spec/dummy/bin/rake +4 -0
  93. data/spec/dummy/bin/setup +35 -0
  94. data/spec/dummy/config/application.rb +48 -0
  95. data/spec/dummy/config/boot.rb +5 -0
  96. data/spec/dummy/config/cable.yml +10 -0
  97. data/spec/dummy/config/ci.rb +15 -0
  98. data/spec/dummy/config/database.yml +15 -0
  99. data/spec/dummy/config/environment.rb +5 -0
  100. data/spec/dummy/config/environments/development.rb +47 -0
  101. data/spec/dummy/config/environments/test.rb +53 -0
  102. data/spec/dummy/config/initializers/content_security_policy.rb +29 -0
  103. data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  104. data/spec/dummy/config/initializers/inflections.rb +16 -0
  105. data/spec/dummy/config/locales/en.yml +31 -0
  106. data/spec/dummy/config/puma.rb +39 -0
  107. data/spec/dummy/config/routes.rb +3 -0
  108. data/spec/dummy/config/storage.yml +27 -0
  109. data/spec/dummy/config.ru +6 -0
  110. data/spec/dummy/db/seeds.rb +52 -0
  111. data/spec/dummy/log/.keep +0 -0
  112. data/spec/dummy/public/400.html +135 -0
  113. data/spec/dummy/public/404.html +135 -0
  114. data/spec/dummy/public/406-unsupported-browser.html +135 -0
  115. data/spec/dummy/public/422.html +135 -0
  116. data/spec/dummy/public/500.html +135 -0
  117. data/spec/dummy/public/icon.png +0 -0
  118. data/spec/dummy/public/icon.svg +3 -0
  119. data/spec/lib/accountant_spec.rb +236 -0
  120. data/spec/lib/book_spec.rb +91 -0
  121. data/spec/lib/diff_spec.rb +102 -0
  122. data/spec/lib/event_spec.rb +53 -0
  123. data/spec/lib/list/activity_spec.rb +117 -0
  124. data/spec/lib/schedule_spec.rb +106 -0
  125. data/spec/lib/source_spec.rb +31 -0
  126. data/spec/models/account_spec.rb +48 -0
  127. data/spec/models/activity_spec.rb +139 -0
  128. data/spec/models/entry_spec.rb +41 -0
  129. data/spec/models/entry_type_spec.rb +43 -0
  130. data/spec/rate_spec.rb +83 -0
  131. data/spec/spec_helper.rb +36 -0
  132. data/spec/support/test_helpers.rb +25 -0
  133. metadata +193 -16
  134. data/LICENSE.txt +0 -22
data/db/seeds.rb ADDED
@@ -0,0 +1,18 @@
1
+ puts "Loading db/seeds... (morty)"
2
+
3
+ Morty::Ledger.seed 'default'
4
+
5
+ Morty::Seed.account_types %w[
6
+ A asset DR
7
+ L liability CR
8
+ E equity CR
9
+ R revenue CR
10
+ X expense DR
11
+ ]
12
+
13
+ Morty::ActivityType.seed *%w[
14
+ adjustment
15
+ cancel
16
+ return
17
+ reversal
18
+ ]
@@ -0,0 +1,479 @@
1
+ CREATE SCHEMA morty;
2
+
3
+ SET search_path=morty;
4
+
5
+ CREATE TABLE ledgers (
6
+ ledger_id SMALLSERIAL PRIMARY KEY
7
+ , ledger TEXT NOT NULL UNIQUE
8
+ , last_closed_on DATE
9
+ );
10
+
11
+ CREATE TABLE account_types (
12
+ account_type_id CHAR(1) PRIMARY KEY CHECK (account_type_id IN ('A', 'L', 'E', 'R', 'X'))
13
+ , account_type TEXT NOT NULL UNIQUE
14
+ , normal_balance CHAR(2) NOT NULL CHECK (normal_balance IN ('DR', 'CR'))
15
+ );
16
+
17
+ CREATE TABLE accounts (
18
+ account_id SMALLSERIAL PRIMARY KEY
19
+
20
+ , account_type_id CHAR(1) NOT NULL REFERENCES account_types
21
+
22
+ , active BOOLEAN NOT NULL DEFAULT TRUE
23
+ , contra BOOLEAN NOT NULL DEFAULT FALSE
24
+ , account TEXT NOT NULL UNIQUE
25
+ , account_code VARCHAR(20) UNIQUE
26
+ );
27
+
28
+ CREATE TABLE activity_types (
29
+ activity_type_id SMALLSERIAL PRIMARY KEY
30
+ , activity_type TEXT NOT NULL UNIQUE
31
+ );
32
+
33
+ CREATE TABLE activities (
34
+ activity_id BIGSERIAL PRIMARY KEY
35
+ , idempotent_uuid UUID UNIQUE
36
+
37
+ , activity_type_id SMALLINT NOT NULL REFERENCES activity_types
38
+
39
+ , source_id INTEGER NOT NULL
40
+
41
+ , accounting_date DATE NOT NULL CHECK (accounting_date <= CURRENT_DATE)
42
+ , effective_date DATE NOT NULL
43
+
44
+ -- Use DECIMAL(20,8) to represent cryptocurrencies
45
+ , activity_amount DECIMAL(8,2) CHECK (activity_amount > 0)
46
+
47
+ , cancels_id BIGINT REFERENCES activities
48
+
49
+ , created_at TIMESTAMPTZ NOT NULL DEFAULT now()
50
+ , updated_at TIMESTAMPTZ
51
+
52
+ -- is this right? do we want to support payments recorded today, but effective on some future date?
53
+ , CHECK (effective_date <= accounting_date)
54
+
55
+ -- Can't cancel yourself
56
+ , CHECK (cancels_id <> activity_id)
57
+ );
58
+
59
+ CREATE INDEX ON activities (source_id, effective_date);
60
+ CREATE INDEX ON activities (activity_type_id);
61
+ CREATE INDEX ON activities USING brin (accounting_date);
62
+ CREATE UNIQUE INDEX ON activities (cancels_id) WHERE cancels_id IS NOT NULL;
63
+
64
+ CREATE TABLE entry_types (
65
+ entry_type_id SMALLSERIAL PRIMARY KEY
66
+
67
+ , ledger_id SMALLINT NOT NULL DEFAULT 1 REFERENCES ledgers
68
+ , dr_id SMALLINT NOT NULL REFERENCES accounts
69
+ , cr_id SMALLINT NOT NULL REFERENCES accounts
70
+
71
+ -- Avoid non-sensical txns
72
+ , CHECK (dr_id <> cr_id)
73
+
74
+ , UNIQUE (ledger_id, dr_id, cr_id)
75
+ );
76
+
77
+ CREATE TABLE entries (
78
+ entry_id BIGSERIAL PRIMARY KEY
79
+
80
+ , activity_id BIGINT NOT NULL REFERENCES activities
81
+ , entry_type_id SMALLINT NOT NULL REFERENCES entry_types
82
+
83
+ , amount DECIMAL(8,2) NOT NULL CHECK (amount > 0)
84
+
85
+ , created_at TIMESTAMPTZ NOT NULL DEFAULT now()
86
+
87
+ , UNIQUE (activity_id, entry_type_id)
88
+ );
89
+
90
+ CREATE INDEX ON entries (entry_type_id);
91
+ CREATE INDEX ON entries USING BRIN (created_at);
92
+
93
+ CREATE VIEW drs AS
94
+ SELECT
95
+ activity_id
96
+ , entry_id
97
+ , ledger
98
+ , account
99
+ , amount
100
+
101
+ FROM entries
102
+
103
+ JOIN entry_types USING (entry_type_id)
104
+ JOIN ledgers USING (ledger_id)
105
+ JOIN accounts ON dr_id = account_id
106
+ ;
107
+
108
+ CREATE VIEW crs AS
109
+ SELECT
110
+ activity_id
111
+ , entry_id
112
+ , ledger
113
+ , account
114
+ , -amount
115
+
116
+ FROM entries
117
+
118
+ JOIN entry_types USING (entry_type_id)
119
+ JOIN ledgers USING (ledger_id)
120
+ JOIN accounts ON cr_id = account_id
121
+ ;
122
+
123
+ CREATE VIEW details AS
124
+ SELECT * FROM drs JOIN activities USING (activity_id)
125
+ UNION ALL
126
+ SELECT * FROM crs JOIN activities USING (activity_id)
127
+ ;
128
+
129
+ CREATE VIEW ledger_balances AS
130
+ SELECT
131
+ ledger
132
+ , account_type
133
+ , account
134
+ , SUM(amount) AS balance
135
+
136
+ FROM details
137
+
138
+ JOIN accounts USING (account)
139
+ JOIN account_types USING (account_type_id)
140
+
141
+ GROUP BY ledger, account, account_type
142
+ ;
143
+
144
+ CREATE VIEW balances AS
145
+ SELECT
146
+ source_id
147
+ , ledger
148
+ , account
149
+ , account_type
150
+ , SUM(amount) AS balance
151
+ FROM details
152
+ JOIN accounts USING (account)
153
+ JOIN account_types USING (account_type_id)
154
+ GROUP BY source_id, ledger, account, account_type;
155
+
156
+ CREATE VIEW trial_balance AS
157
+ SELECT
158
+ ledger
159
+ , SUM(amount) AS balance
160
+
161
+ FROM details
162
+
163
+ GROUP BY ledger
164
+ ;
165
+
166
+ CREATE VIEW errors AS
167
+ SELECT * FROM (
168
+ SELECT
169
+ activity_id
170
+ , ledger
171
+ , activity_type
172
+ , activity_amount
173
+ , SUM(amount) AS entry_sum
174
+
175
+ FROM activities
176
+
177
+ JOIN activity_types USING (activity_type_id)
178
+ JOIN entries USING (activity_id)
179
+ JOIN entry_types USING (entry_type_id)
180
+ JOIN ledgers USING (ledger_id)
181
+
182
+ WHERE activity_amount IS NOT NULL
183
+ GROUP BY activity_id, ledger, activity_type, activity_amount
184
+ ) sums
185
+
186
+ WHERE activity_amount <> entry_sum
187
+ ;
188
+
189
+ CREATE SCHEMA morty_archive;
190
+
191
+ SET search_path=morty_archive;
192
+
193
+ CREATE TABLE activities (LIKE morty.activities INCLUDING ALL);
194
+ CREATE TABLE entries (LIKE morty.entries INCLUDING ALL);
195
+
196
+ DO $$
197
+ DECLARE
198
+ r RECORD;
199
+ BEGIN
200
+ FOR r IN
201
+ SELECT conname, conrelid::regclass AS table_name
202
+ FROM pg_constraint
203
+ WHERE contype = 'f'
204
+ AND connamespace = 'morty_archive'::regnamespace
205
+ LOOP
206
+ EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.table_name, r.conname);
207
+ END LOOP;
208
+ END $$;
209
+
210
+ CREATE VIEW drs AS
211
+ SELECT
212
+ activity_id
213
+ , entry_id
214
+ , ledger
215
+ , account
216
+ , amount
217
+
218
+ FROM entries
219
+
220
+ JOIN morty.entry_types USING (entry_type_id)
221
+ JOIN morty.ledgers USING (ledger_id)
222
+ JOIN morty.accounts ON dr_id = account_id
223
+ ;
224
+
225
+ CREATE VIEW crs AS
226
+ SELECT
227
+ activity_id
228
+ , entry_id
229
+ , ledger
230
+ , account
231
+ , -amount
232
+
233
+ FROM entries
234
+
235
+ JOIN morty.entry_types USING (entry_type_id)
236
+ JOIN morty.ledgers USING (ledger_id)
237
+ JOIN morty.accounts ON cr_id = account_id
238
+ ;
239
+
240
+ CREATE VIEW details AS
241
+ SELECT * FROM drs JOIN activities USING (activity_id)
242
+ UNION ALL
243
+ SELECT * FROM crs JOIN activities USING (activity_id)
244
+ ;
245
+
246
+ CREATE VIEW ledger_balances AS
247
+ SELECT
248
+ ledger
249
+ , account_type
250
+ , account
251
+ , SUM(amount) AS balance
252
+
253
+ FROM details
254
+
255
+ JOIN morty.accounts USING (account)
256
+ JOIN morty.account_types USING (account_type_id)
257
+
258
+ GROUP BY ledger, account, account_type
259
+ ;
260
+
261
+ CREATE VIEW balances AS
262
+ SELECT
263
+ source_id
264
+ , ledger
265
+ , account
266
+ , account_type
267
+ , SUM(amount) AS balance
268
+ FROM details
269
+
270
+ JOIN morty.accounts USING (account)
271
+ JOIN morty.account_types USING (account_type_id)
272
+
273
+ GROUP BY source_id, ledger, account, account_type;
274
+
275
+ CREATE VIEW morty.all_details AS
276
+ SELECT * FROM morty.details
277
+ UNION ALL
278
+ SELECT * FROM morty_archive.details
279
+ ;
280
+
281
+ CREATE VIEW morty.all_balances AS
282
+ SELECT
283
+ source_id
284
+ , ledger
285
+ , account
286
+ , account_type
287
+ , SUM(amount) AS balance
288
+ FROM morty.all_details
289
+ JOIN morty.accounts USING (account)
290
+ JOIN morty.account_types USING (account_type_id)
291
+ GROUP BY source_id, ledger, account, account_type;
292
+
293
+ CREATE VIEW morty.all_ledger_balances AS
294
+ SELECT
295
+ ledger
296
+ , account_type
297
+ , account
298
+ , SUM(amount) AS balance
299
+
300
+ FROM morty.all_details
301
+
302
+ JOIN morty.accounts USING (account)
303
+ JOIN morty.account_types USING (account_type_id)
304
+
305
+ GROUP BY ledger, account_type, account;
306
+
307
+
308
+
309
+ -- Shared trigger functions
310
+ CREATE OR REPLACE FUNCTION morty.prevent_delete() RETURNS TRIGGER AS $$
311
+ BEGIN
312
+ IF current_setting('morty.allow_mutations', true) = 'true' THEN
313
+ RETURN OLD;
314
+ END IF;
315
+ RAISE EXCEPTION 'deletes not allowed on %', TG_TABLE_NAME;
316
+ END;
317
+ $$ LANGUAGE plpgsql;
318
+
319
+ CREATE OR REPLACE FUNCTION morty.prevent_update() RETURNS TRIGGER AS $$
320
+ BEGIN
321
+ IF current_setting('morty.allow_mutations', true) = 'true' THEN
322
+ RETURN NEW;
323
+ END IF;
324
+ RAISE EXCEPTION 'updates not allowed on %', TG_TABLE_NAME;
325
+ END;
326
+ $$ LANGUAGE plpgsql;
327
+
328
+
329
+
330
+ DO $$
331
+ DECLARE
332
+ t TEXT;
333
+ BEGIN
334
+ FOREACH t IN ARRAY ARRAY[
335
+ 'morty.account_types',
336
+ 'morty.activities',
337
+ 'morty.entry_types',
338
+ 'morty.entries',
339
+
340
+ 'morty_archive.activities',
341
+ 'morty_archive.entries'
342
+ ]
343
+ LOOP
344
+ EXECUTE format('CREATE TRIGGER no_delete BEFORE DELETE ON %s
345
+ FOR EACH ROW EXECUTE FUNCTION morty.prevent_delete()', t);
346
+ EXECUTE format('CREATE TRIGGER no_update BEFORE UPDATE ON %s
347
+ FOR EACH ROW EXECUTE FUNCTION morty.prevent_update()', t);
348
+ END LOOP;
349
+ END $$;
350
+
351
+
352
+
353
+ CREATE OR REPLACE FUNCTION morty.archive_source(p_source_id INTEGER)
354
+ RETURNS TABLE(archived_activities BIGINT, archived_entries BIGINT) AS $$
355
+ DECLARE
356
+ activity_count BIGINT;
357
+ entry_count BIGINT;
358
+ BEGIN
359
+ IF EXISTS (
360
+ SELECT 1 FROM morty.activities a
361
+ JOIN morty.entries e USING (activity_id)
362
+ JOIN morty.entry_types et USING (entry_type_id)
363
+ JOIN morty.ledgers l USING (ledger_id)
364
+ WHERE a.source_id = p_source_id
365
+ AND (l.last_closed_on IS NULL OR a.accounting_date > l.last_closed_on)
366
+ ) THEN
367
+ RAISE EXCEPTION 'source % has entries in an open period', p_source_id;
368
+ END IF;
369
+
370
+ PERFORM set_config('morty.allow_mutations', 'true', true);
371
+
372
+ WITH moved AS (
373
+ DELETE FROM morty.entries
374
+ WHERE activity_id IN (
375
+ SELECT activity_id FROM morty.activities WHERE source_id = p_source_id
376
+ )
377
+ RETURNING *
378
+ )
379
+ INSERT INTO morty_archive.entries SELECT * FROM moved;
380
+
381
+ GET DIAGNOSTICS entry_count = ROW_COUNT;
382
+
383
+ WITH moved AS (
384
+ DELETE FROM morty.activities
385
+ WHERE source_id = p_source_id
386
+ RETURNING *
387
+ )
388
+ INSERT INTO morty_archive.activities SELECT * FROM moved;
389
+
390
+ GET DIAGNOSTICS activity_count = ROW_COUNT;
391
+
392
+ PERFORM set_config('morty.allow_mutations', '', true);
393
+
394
+ RETURN QUERY SELECT activity_count, entry_count;
395
+ END;
396
+ $$ LANGUAGE plpgsql;
397
+
398
+
399
+
400
+ CREATE OR REPLACE FUNCTION morty.archive_through(p_through DATE)
401
+ RETURNS TABLE(archived_activities BIGINT, archived_entries BIGINT) AS $$
402
+ DECLARE
403
+ activity_count BIGINT;
404
+ entry_count BIGINT;
405
+ BEGIN
406
+ IF p_through >= CURRENT_DATE THEN
407
+ RAISE EXCEPTION 'cannot archive current or future';
408
+ END IF;
409
+
410
+ IF EXISTS (
411
+ SELECT 1 FROM morty.ledgers
412
+ WHERE last_closed_on IS NULL OR last_closed_on < p_through
413
+ ) THEN
414
+ RAISE EXCEPTION 'all ledgers must be closed through % before archiving', p_through;
415
+ END IF;
416
+
417
+ PERFORM set_config('morty.allow_mutations', 'true', true);
418
+
419
+ WITH moved AS (
420
+ DELETE FROM morty.entries
421
+ WHERE activity_id IN (
422
+ SELECT activity_id FROM morty.activities WHERE accounting_date <= p_through
423
+ )
424
+ RETURNING *
425
+ )
426
+ INSERT INTO morty_archive.entries SELECT * FROM moved;
427
+
428
+ GET DIAGNOSTICS entry_count = ROW_COUNT;
429
+
430
+ WITH moved AS (
431
+ DELETE FROM morty.activities
432
+ WHERE accounting_date <= p_through
433
+ RETURNING *
434
+ )
435
+ INSERT INTO morty_archive.activities SELECT * FROM moved;
436
+
437
+ GET DIAGNOSTICS activity_count = ROW_COUNT;
438
+
439
+ PERFORM set_config('morty.allow_mutations', '', true);
440
+
441
+ RETURN QUERY SELECT activity_count, entry_count;
442
+ END;
443
+ $$ LANGUAGE plpgsql;
444
+
445
+
446
+ CREATE OR REPLACE FUNCTION morty.close_period(p_ledger_id SMALLINT, p_through DATE)
447
+ RETURNS VOID AS $$
448
+ DECLARE
449
+ balance DECIMAL;
450
+ BEGIN
451
+ IF p_through >= CURRENT_DATE THEN
452
+ RAISE EXCEPTION 'cannot close current or future periods';
453
+ END IF;
454
+
455
+ SELECT SUM(amount) INTO balance
456
+ FROM morty.details d
457
+ WHERE d.ledger = (SELECT ledger FROM morty.ledgers WHERE ledger_id = p_ledger_id)
458
+ AND d.accounting_date <= p_through;
459
+
460
+ IF COALESCE(balance, 0) <> 0 THEN
461
+ RAISE EXCEPTION 'ledger % does not balance through % (off by %)', p_ledger_id, p_through, balance;
462
+ END IF;
463
+
464
+ PERFORM set_config('morty.allow_mutations', 'true', true);
465
+
466
+ UPDATE morty.ledgers
467
+ SET last_closed_on = p_through
468
+ WHERE ledger_id = p_ledger_id
469
+ AND (last_closed_on IS NULL OR last_closed_on < p_through);
470
+
471
+ PERFORM set_config('morty.allow_mutations', '', true);
472
+ END;
473
+ $$ LANGUAGE plpgsql;
474
+
475
+
476
+ /*
477
+ SELECT * FROM morty.archive_source(1234);
478
+ SELECT * FROM morty.archive_through('2025-12-31');
479
+ */
@@ -0,0 +1,47 @@
1
+ Feature: Accountant
2
+
3
+ Background:
4
+ Given a default accountant
5
+
6
+ Scenario: Missing source
7
+ Given a sourceless accountant
8
+ Then I cannot save
9
+
10
+ Scenario: Invalid source (missing class)
11
+ Given the accountant:
12
+ """
13
+ class InvalidSourceAccountant < Morty::Accountant
14
+ source :missing
15
+ end
16
+ """
17
+ Then the accountant is invalid
18
+
19
+ Scenario: Invalid source (missing id)
20
+ Given the accountant:
21
+ """
22
+ class SourceExample
23
+ end
24
+
25
+ class InvalidSourceExampleAccountant < Morty::Accountant
26
+ source :source_example
27
+ end
28
+ """
29
+ Then the accountant is invalid
30
+
31
+ Scenario: Multiple sources
32
+ Given the configuration:
33
+ """
34
+ class Source1; end
35
+ class Source2; end
36
+
37
+ @accountant.source = :source1
38
+ @accountant.source = :source2
39
+ """
40
+ Then the accountant is invalid
41
+
42
+ Scenario: Future start date
43
+ Given the configuration:
44
+ """
45
+ @accountant.start_date = Date.current + 1
46
+ """
47
+ Then the accountant is invalid
@@ -0,0 +1,79 @@
1
+ Feature: Adjustment
2
+
3
+ An adjustment activity is created when recording an activity with an effective_date in the past
4
+
5
+ Background:
6
+ Given an adjusting accountant
7
+ And a start date of 2016-01-01
8
+ And an interest rate of 36.5%
9
+
10
+ And I simulate these activities:
11
+ | issue | 2026-01-01 | 1000.00 |
12
+
13
+ Then I have 1 issue activity
14
+
15
+ Scenario: One retroactive payment
16
+ When I simulate until 2026-01-03
17
+ And I apply a payment effective 2026-01-02 for $500.00
18
+
19
+ Then I have these balances:
20
+ | cash | -500.00 |
21
+ | principal | 501.00 |
22
+ | interest | 0.50 |
23
+ | revenue | -1.50 |
24
+
25
+ And I have these activity counts:
26
+ | issue | 1 |
27
+ | payment | 1 |
28
+ | adjustment | 1 |
29
+ | interest | 2 |
30
+
31
+ Scenario: Two retroactive payments
32
+ When I simulate until 2026-01-04
33
+ And I apply a payment effective 2026-01-03 for $500.00
34
+
35
+ Then I have these balances:
36
+ | cash | -500.00 |
37
+ | principal | 502.00 |
38
+ | interest | 0.50 |
39
+ | revenue | -2.50 |
40
+
41
+ When I simulate until 2026-01-05
42
+ And I apply a payment effective 2026-01-02 for $400.00
43
+
44
+ Then I have these balances:
45
+ | cash | -100.00 |
46
+ | principal | 101.60 |
47
+ | interest | 0.20 |
48
+ | revenue | -1.80 |
49
+
50
+ And I have these activity counts:
51
+ | issue | 1 |
52
+ | payment | 2 |
53
+ | adjustment | 2 |
54
+ | interest | 4 |
55
+
56
+ Scenario: Two retroactive payments interleaved
57
+ When I simulate until 2026-01-04
58
+ And I apply a payment effective 2026-01-02 for $400.00
59
+
60
+ Then I have these balances:
61
+ | cash | -600.00 |
62
+ | principal | 601.00 |
63
+ | interest | 1.20 |
64
+ | revenue | -2.20 |
65
+
66
+ When I simulate until 2026-01-05
67
+ And I apply a payment effective 2026-01-03 for $500.00
68
+
69
+ Then I have these balances:
70
+ | cash | -100.00 |
71
+ | principal | 101.60 |
72
+ | interest | 0.20 |
73
+ | revenue | -1.80 |
74
+
75
+ And these activity counts:
76
+ | issue | 1 |
77
+ | payment | 2 |
78
+ | adjustment | 2 |
79
+ | interest | 4 |