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.
- checksums.yaml +5 -5
- data/.github/dependabot.yml +12 -0
- data/.github/workflows/ci.yml +107 -0
- data/.gitignore +17 -3
- data/.rubocop.yml +20 -0
- data/Appraisals +24 -0
- data/Gemfile +28 -1
- data/LICENSE +21 -0
- data/README.md +37 -7
- data/Rakefile +37 -0
- data/app/models/morty/account.rb +37 -0
- data/app/models/morty/account_type.rb +7 -0
- data/app/models/morty/activity.rb +147 -0
- data/app/models/morty/activity_type.rb +7 -0
- data/app/models/morty/application_record.rb +6 -0
- data/app/models/morty/entry.rb +24 -0
- data/app/models/morty/entry_type.rb +23 -0
- data/app/models/morty/ledger.rb +8 -0
- data/config/routes.rb +2 -0
- data/config.ru +7 -0
- data/cucumber.yml +2 -0
- data/db/migrate/20260224063053_create_morty_schema.rb +17 -0
- data/db/seeds.rb +18 -0
- data/db/sql/create_morty_schema.sql +479 -0
- data/features/accountant.feature +47 -0
- data/features/adjustment.feature +79 -0
- data/features/cancel.feature +130 -0
- data/features/daily.feature +42 -0
- data/features/default.feature +33 -0
- data/features/ledger.feature +57 -0
- data/features/retroactive.feature +92 -0
- data/features/return.feature +112 -0
- data/features/reversal.feature +57 -0
- data/features/simulation.feature +128 -0
- data/features/support/accountants/adjusting_accountant.rb +34 -0
- data/features/support/accountants/daily_accountant.rb +13 -0
- data/features/support/accountants/default_accountant.rb +2 -0
- data/features/support/accountants/defaulting_accountant.rb +32 -0
- data/features/support/accountants/multiple_ledgers_accountant.rb +51 -0
- data/features/support/accountants/simulating_accountant.rb +36 -0
- data/features/support/accountants/sourceless_accountant.rb +2 -0
- data/features/support/accountants/waterfalling_accountant.rb +15 -0
- data/features/support/env.rb +17 -0
- data/features/waterfall.feature +34 -0
- data/gemfiles/rails_7.0.gemfile +30 -0
- data/gemfiles/rails_7.0.gemfile.lock +494 -0
- data/gemfiles/rails_7.1.gemfile +30 -0
- data/gemfiles/rails_7.1.gemfile.lock +543 -0
- data/gemfiles/rails_7.2.gemfile +30 -0
- data/gemfiles/rails_7.2.gemfile.lock +539 -0
- data/gemfiles/rails_8.0.gemfile +30 -0
- data/gemfiles/rails_8.0.gemfile.lock +536 -0
- data/gemfiles/rails_8.1.gemfile +30 -0
- data/gemfiles/rails_8.1.gemfile.lock +538 -0
- data/lib/morty/accountant.rb +332 -0
- data/lib/morty/adjustment.rb +64 -0
- data/lib/morty/book.rb +54 -0
- data/lib/morty/context/activity.rb +52 -0
- data/lib/morty/context/daily.rb +23 -0
- data/lib/morty/context/simulation.rb +26 -0
- data/lib/morty/cucumber/helpers.rb +27 -0
- data/lib/morty/cucumber/steps.rb +191 -0
- data/lib/morty/diff.rb +71 -0
- data/lib/morty/dsl.rb +86 -0
- data/lib/morty/engine.rb +21 -0
- data/lib/morty/error.rb +3 -0
- data/lib/morty/event.rb +27 -0
- data/lib/morty/list/activity.rb +57 -0
- data/lib/morty/rate.rb +59 -0
- data/lib/morty/schedule.rb +36 -0
- data/lib/morty/seed.rb +60 -0
- data/lib/morty/source.rb +19 -0
- data/lib/morty/tasks/morty_tasks.rake +4 -0
- data/lib/morty/version.rb +1 -1
- data/lib/morty.rb +27 -1
- data/morty.gemspec +22 -19
- data/spec/dummy/Rakefile +6 -0
- data/spec/dummy/app/assets/images/.keep +0 -0
- data/spec/dummy/app/assets/stylesheets/application.css +15 -0
- data/spec/dummy/app/controllers/application_controller.rb +3 -0
- data/spec/dummy/app/controllers/concerns/.keep +0 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/jobs/application_job.rb +7 -0
- data/spec/dummy/app/models/application_record.rb +3 -0
- data/spec/dummy/app/models/concerns/.keep +0 -0
- data/spec/dummy/app/views/layouts/application.html.erb +28 -0
- data/spec/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/spec/dummy/app/views/pwa/service-worker.js +26 -0
- data/spec/dummy/bin/ci +6 -0
- data/spec/dummy/bin/dev +2 -0
- data/spec/dummy/bin/rails +4 -0
- data/spec/dummy/bin/rake +4 -0
- data/spec/dummy/bin/setup +35 -0
- data/spec/dummy/config/application.rb +48 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/cable.yml +10 -0
- data/spec/dummy/config/ci.rb +15 -0
- data/spec/dummy/config/database.yml +15 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +47 -0
- data/spec/dummy/config/environments/test.rb +53 -0
- data/spec/dummy/config/initializers/content_security_policy.rb +29 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/spec/dummy/config/initializers/inflections.rb +16 -0
- data/spec/dummy/config/locales/en.yml +31 -0
- data/spec/dummy/config/puma.rb +39 -0
- data/spec/dummy/config/routes.rb +3 -0
- data/spec/dummy/config/storage.yml +27 -0
- data/spec/dummy/config.ru +6 -0
- data/spec/dummy/db/seeds.rb +52 -0
- data/spec/dummy/log/.keep +0 -0
- data/spec/dummy/public/400.html +135 -0
- data/spec/dummy/public/404.html +135 -0
- data/spec/dummy/public/406-unsupported-browser.html +135 -0
- data/spec/dummy/public/422.html +135 -0
- data/spec/dummy/public/500.html +135 -0
- data/spec/dummy/public/icon.png +0 -0
- data/spec/dummy/public/icon.svg +3 -0
- data/spec/lib/accountant_spec.rb +236 -0
- data/spec/lib/book_spec.rb +91 -0
- data/spec/lib/diff_spec.rb +102 -0
- data/spec/lib/event_spec.rb +53 -0
- data/spec/lib/list/activity_spec.rb +117 -0
- data/spec/lib/schedule_spec.rb +106 -0
- data/spec/lib/source_spec.rb +31 -0
- data/spec/models/account_spec.rb +48 -0
- data/spec/models/activity_spec.rb +139 -0
- data/spec/models/entry_spec.rb +41 -0
- data/spec/models/entry_type_spec.rb +43 -0
- data/spec/rate_spec.rb +83 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/support/test_helpers.rb +25 -0
- metadata +193 -16
- 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 |
|