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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
|
|
3
|
+
<html lang="en">
|
|
4
|
+
|
|
5
|
+
<head>
|
|
6
|
+
|
|
7
|
+
<title>We're sorry, but something went wrong (500 Internal Server Error)</title>
|
|
8
|
+
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="initial-scale=1, width=device-width">
|
|
11
|
+
<meta name="robots" content="noindex, nofollow">
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
|
|
15
|
+
*, *::before, *::after {
|
|
16
|
+
box-sizing: border-box;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
* {
|
|
20
|
+
margin: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
html {
|
|
24
|
+
font-size: 16px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
background: #FFF;
|
|
29
|
+
color: #261B23;
|
|
30
|
+
display: grid;
|
|
31
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
32
|
+
font-size: clamp(1rem, 2.5vw, 2rem);
|
|
33
|
+
-webkit-font-smoothing: antialiased;
|
|
34
|
+
font-style: normal;
|
|
35
|
+
font-weight: 400;
|
|
36
|
+
letter-spacing: -0.0025em;
|
|
37
|
+
line-height: 1.4;
|
|
38
|
+
min-height: 100dvh;
|
|
39
|
+
place-items: center;
|
|
40
|
+
text-rendering: optimizeLegibility;
|
|
41
|
+
-webkit-text-size-adjust: 100%;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#error-description {
|
|
45
|
+
fill: #d30001;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#error-id {
|
|
49
|
+
fill: #f0eff0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@media (prefers-color-scheme: dark) {
|
|
53
|
+
body {
|
|
54
|
+
background: #101010;
|
|
55
|
+
color: #e0e0e0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#error-description {
|
|
59
|
+
fill: #FF6161;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#error-id {
|
|
63
|
+
fill: #2c2c2c;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
a {
|
|
68
|
+
color: inherit;
|
|
69
|
+
font-weight: 700;
|
|
70
|
+
text-decoration: underline;
|
|
71
|
+
text-underline-offset: 0.0925em;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
b, strong {
|
|
75
|
+
font-weight: 700;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
i, em {
|
|
79
|
+
font-style: italic;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
main {
|
|
83
|
+
display: grid;
|
|
84
|
+
gap: 1em;
|
|
85
|
+
padding: 2em;
|
|
86
|
+
place-items: center;
|
|
87
|
+
text-align: center;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
main header {
|
|
91
|
+
width: min(100%, 12em);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main header svg {
|
|
95
|
+
height: auto;
|
|
96
|
+
max-width: 100%;
|
|
97
|
+
width: 100%;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
main article {
|
|
101
|
+
width: min(100%, 30em);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main article p {
|
|
105
|
+
font-size: 75%;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main article br {
|
|
109
|
+
display: none;
|
|
110
|
+
|
|
111
|
+
@media(min-width: 48em) {
|
|
112
|
+
display: inline;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
</style>
|
|
117
|
+
|
|
118
|
+
</head>
|
|
119
|
+
|
|
120
|
+
<body>
|
|
121
|
+
|
|
122
|
+
<!-- This file lives in public/500.html -->
|
|
123
|
+
|
|
124
|
+
<main>
|
|
125
|
+
<header>
|
|
126
|
+
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m101.23 93.8427c-8.1103 0-15.4098 3.7849-19.7354 8.3813h-36.2269v-99.21891h103.8143v37.03791h-68.3984v24.8722c5.1366-2.7035 15.1396-5.9477 24.6014-5.9477 35.146 0 56.233 22.7094 56.233 55.4215 0 34.605-23.791 57.315-60.558 57.315-37.8492 0-61.64-22.169-63.8028-55.963h42.9857c1.0814 10.814 9.1919 19.195 21.6281 19.195 11.355 0 19.465-8.381 19.465-20.547 0-11.625-7.299-20.5463-20.006-20.5463zm138.833 77.8613c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" id="error-id"/><path d="m23.1377 68.9967v34.0033h-8.9162v-34.0033zm4.3157 34.0033v-24.921h8.6947v2.1598c1.3845-1.5506 3.8212-2.7136 6.701-2.7136 5.538 0 8.8054 3.5997 8.8054 9.1377v16.3371h-8.6393v-14.2327c0-2.049-1.0522-3.5443-3.2674-3.5443-1.7168 0-3.1567.9969-3.5997 2.7136v15.0634zm29.9913-8.5839v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.5839v6.8671h5.2058v6.7564h-5.2058v8.307c0 1.9383.9415 2.769 2.6583 2.769.9414 0 1.9937-.2216 2.769-.5538v7.3654c-.9969.443-2.8798.775-4.8181.775-5.8703 0-9.1931-2.769-9.1931-9.0819zm32.3666-.1108h8.0301c-.8861 5.7597-5.2057 9.2487-11.6852 9.2487-7.6424 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.3165-13.0143 12.5159-13.0143 7.6424 0 11.9621 5.095 11.9621 12.5159v2.1598h-16.1156c.2769 2.9905 1.8275 4.5965 4.3196 4.5965 1.7722 0 3.1567-.7753 3.6551-2.4921zm-3.8212-10.0237c-2.0491 0-3.4336 1.2737-3.9874 3.5997h7.5317c-.1107-2.0491-1.3845-3.5997-3.5443-3.5997zm31.4299-6.3134v8.3624c-1.052-.5538-2.215-.7753-3.599-.7753-2.382 0-3.988 1.0522-4.431 2.8244v14.6203h-8.694v-24.921h8.694v2.2152c1.219-1.6614 3.157-2.769 5.649-2.769 1.108 0 1.994.2215 2.381.443zm2.949 25.0318v-24.921h8.694v2.1598c1.385-1.5506 3.821-2.7136 6.701-2.7136 5.538 0 8.806 3.5997 8.806 9.1377v16.3371h-8.64v-14.2327c0-2.049-1.052-3.5443-3.267-3.5443-1.717 0-3.157.9969-3.6 2.7136v15.0634zm50.371 0h-8.363v-1.274c-.83.831-3.323 1.717-5.981 1.717-4.929 0-9.082-2.769-9.082-8.0301 0-4.818 4.153-7.9193 9.581-7.9193 2.049 0 4.485.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.046-2.9905-1.606 0-2.547.7199-2.935 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.433.7199-3.433 2.3813 0 1.7168 1.716 2.4367 3.433 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm20.742-29.0191v35.997h-8.694v-35.997zm13.036 25.9178h9.248c.72 2.326 2.714 3.489 5.483 3.489 2.713 0 4.596-1.163 4.596-3.2674 0-1.6061-1.052-2.326-3.212-2.8244l-6.534-1.3845c-4.985-1.1076-8.751-3.7105-8.751-9.47 0-6.6456 5.538-11.0206 13.07-11.0206 8.307 0 13.014 4.5411 13.956 10.4114h-8.695c-.72-1.8829-2.27-3.3228-5.205-3.3228-2.548 0-4.265 1.1076-4.265 2.9905 0 1.4953 1.052 2.326 2.825 2.7137l6.645 1.5506c5.815 1.3845 9.027 4.5412 9.027 9.8023 0 6.9778-5.87 10.9654-13.291 10.9654-8.141 0-13.679-3.9322-14.897-10.6332zm46.509 1.3845h8.031c-.887 5.7597-5.206 9.2487-11.686 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.317-13.0143 12.516-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.319 4.5965 1.773 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm31.431-6.3134v8.3624c-1.053-.5538-2.216-.7753-3.6-.7753-2.381 0-3.988 1.0522-4.431 2.8244v14.6203h-8.694v-24.921h8.694v2.2152c1.219-1.6614 3.157-2.769 5.649-2.769 1.108 0 1.994.2215 2.382.443zm18.288 25.0318h-7.809l-9.47-24.921h8.861l4.763 14.288 4.652-14.288h8.528zm25.614-8.6947h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.384-3.5997-3.544-3.5997zm31.43-6.3134v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.157-2.769 5.649-2.769 1.107 0 1.993.2215 2.381.443zm13.703-8.9715h24.312v7.6424h-15.562v5.3165h14.232v7.4763h-14.232v5.8703h15.562v7.6978h-24.312zm44.667 8.9715v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.156-2.769 5.648-2.769 1.108 0 1.994.2215 2.382.443zm19.673 0v8.3624c-1.053-.5538-2.216-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.156-2.769 5.648-2.769 1.108 0 1.994.2215 2.382.443zm26.769 12.5713c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm28.082-12.5713v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.157-2.769 5.649-2.769 1.107 0 1.993.2215 2.381.443z" id="error-description"/></svg>
|
|
127
|
+
</header>
|
|
128
|
+
<article>
|
|
129
|
+
<p><strong>We're sorry, but something went wrong.</strong><br> If you're the application owner check the logs for more information.</p>
|
|
130
|
+
</article>
|
|
131
|
+
</main>
|
|
132
|
+
|
|
133
|
+
</body>
|
|
134
|
+
|
|
135
|
+
</html>
|
|
Binary file
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
# Minimal accountant subclass for unit testing
|
|
4
|
+
class TestAccountant < Morty::Accountant
|
|
5
|
+
activity :issue do
|
|
6
|
+
entry :principal, :cash, amount
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
activity :payment do
|
|
10
|
+
waterfall amount, limit: :cr, complete: true, entries: <<~END
|
|
11
|
+
cash interest
|
|
12
|
+
cash principal
|
|
13
|
+
cash payable
|
|
14
|
+
END
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
balance :accruing, %w[principal]
|
|
18
|
+
|
|
19
|
+
activity :interest do
|
|
20
|
+
if rate = accountant.rate_for(accountant.date.yesterday)
|
|
21
|
+
amount ||= (rate.daily_for(accountant.date.yesterday) * balances[:accruing]).floor(2)
|
|
22
|
+
entry :interest, :revenue, amount
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
daily do
|
|
27
|
+
activity :interest, today
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
daily_guard do
|
|
31
|
+
accountant.activities.none? do |activity|
|
|
32
|
+
activity.effective_date == accountant.date && activity.type?(:interest)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
RSpec.describe Morty::Accountant do
|
|
38
|
+
let(:source) { TestHelpers::SourceStub.new(88888) }
|
|
39
|
+
|
|
40
|
+
def setup_accountant(rates: { Date.new(2020, 1, 1) => "0.365" })
|
|
41
|
+
acc = TestAccountant.new
|
|
42
|
+
acc.rates = rates
|
|
43
|
+
acc.source = source
|
|
44
|
+
acc.start_date = Date.current
|
|
45
|
+
acc
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe "#source=" do
|
|
49
|
+
it "wraps a plain object in Source" do
|
|
50
|
+
acc = TestAccountant.new
|
|
51
|
+
acc.source = source
|
|
52
|
+
expect(acc.source).to be_a(Morty::Source)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "accepts an existing Source without re-wrapping" do
|
|
56
|
+
acc = TestAccountant.new
|
|
57
|
+
wrapped = Morty::Source.new(source)
|
|
58
|
+
acc.source = wrapped
|
|
59
|
+
expect(acc.source).to equal(wrapped)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "raises when setting source twice" do
|
|
63
|
+
acc = TestAccountant.new
|
|
64
|
+
acc.source = source
|
|
65
|
+
expect { acc.source = TestHelpers::SourceStub.new(2) }.to raise_error(Morty::Error, /multiple sources/)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "ignores nil" do
|
|
69
|
+
acc = TestAccountant.new
|
|
70
|
+
acc.source = nil
|
|
71
|
+
expect(acc.source).to be_nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe "#rates=" do
|
|
76
|
+
it "sets rates from a hash" do
|
|
77
|
+
acc = TestAccountant.new
|
|
78
|
+
acc.rates = { Date.new(2020, 1, 1) => "0.10" }
|
|
79
|
+
expect(acc.rates).to be_a(Hash)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "raises when setting rates twice" do
|
|
83
|
+
acc = TestAccountant.new
|
|
84
|
+
acc.rates = { Date.new(2020, 1, 1) => "0.10" }
|
|
85
|
+
expect { acc.rates = { Date.new(2020, 1, 1) => "0.20" } }.to raise_error(Morty::Error, /rates already set/)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe "#start_date=" do
|
|
90
|
+
it "raises for invalid date" do
|
|
91
|
+
acc = TestAccountant.new
|
|
92
|
+
acc.rates = { Date.new(2020, 1, 1) => "0.10" }
|
|
93
|
+
acc.source = source
|
|
94
|
+
expect { acc.start_date = 12345 }.to raise_error(Morty::Error, /invalid date/)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "raises when set twice" do
|
|
98
|
+
acc = setup_accountant
|
|
99
|
+
expect { acc.start_date = Date.current }.to raise_error(Morty::Error, /start_date already set/)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "raises for future start_date" do
|
|
103
|
+
acc = TestAccountant.new
|
|
104
|
+
acc.rates = { Date.new(2020, 1, 1) => "0.10" }
|
|
105
|
+
acc.source = source
|
|
106
|
+
expect { acc.start_date = Date.current + 1 }.to raise_error(Morty::Error, /future/)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe "#check_setup" do
|
|
111
|
+
it "raises when source is missing" do
|
|
112
|
+
acc = TestAccountant.new
|
|
113
|
+
expect { acc.send(:check_setup) }.to raise_error(Morty::Error, /missing source/)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "raises when start_date is missing" do
|
|
117
|
+
acc = TestAccountant.new
|
|
118
|
+
acc.source = source
|
|
119
|
+
expect { acc.send(:check_setup) }.to raise_error(Morty::Error, /missing start_date/)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
describe "#rate_for" do
|
|
124
|
+
it "finds the effective rate for a date" do
|
|
125
|
+
acc = setup_accountant(rates: {
|
|
126
|
+
Date.new(2020, 1, 1) => "0.05",
|
|
127
|
+
Date.new(2023, 6, 1) => "0.10"
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
expect(acc.rate_for(Date.new(2024, 1, 1)).yearly).to eq "0.10".to_d
|
|
131
|
+
expect(acc.rate_for(Date.new(2021, 1, 1)).yearly).to eq "0.05".to_d
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "returns nil when no rate matches" do
|
|
135
|
+
acc = setup_accountant(rates: { Date.new(2025, 6, 1) => "0.05" })
|
|
136
|
+
expect(acc.rate_for(Date.new(2020, 1, 1))).to be_nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe "#activity" do
|
|
141
|
+
it "records an activity and applies it to books" do
|
|
142
|
+
acc = setup_accountant
|
|
143
|
+
acc.activity(:issue, Date.current, "500.00".to_d)
|
|
144
|
+
|
|
145
|
+
expect(acc.activities.size).to eq 1
|
|
146
|
+
expect(acc.accounts[:principal]).to eq "500.00".to_d
|
|
147
|
+
expect(acc.accounts[:cash]).to eq "-500.00".to_d
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "raises for missing activity type" do
|
|
151
|
+
acc = setup_accountant
|
|
152
|
+
expect { acc.activity(:nonexistent, Date.current, "100.00".to_d) }.to raise_error(LookupBy::Error)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
describe "#save" do
|
|
157
|
+
it "persists activities to the database" do
|
|
158
|
+
acc = setup_accountant
|
|
159
|
+
acc.activity(:issue, Date.current, "100.00".to_d)
|
|
160
|
+
acc.save
|
|
161
|
+
|
|
162
|
+
expect(Morty::Activity.where(source_id: source.id).count).to eq 1
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "raises when source is missing" do
|
|
166
|
+
acc = TestAccountant.new
|
|
167
|
+
expect { acc.save }.to raise_error(Morty::Error, /missing source/)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe "#simulate_today" do
|
|
172
|
+
it "runs daily and daily_schedule" do
|
|
173
|
+
acc = setup_accountant
|
|
174
|
+
acc.activity(:issue, Date.current, "1000.00".to_d)
|
|
175
|
+
acc.simulate_today
|
|
176
|
+
|
|
177
|
+
expect(acc.activities.count_by_type[:interest]).to eq 1
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "does not run twice for the same date" do
|
|
181
|
+
acc = setup_accountant
|
|
182
|
+
acc.activity(:issue, Date.current, "1000.00".to_d)
|
|
183
|
+
acc.simulate_today
|
|
184
|
+
acc.simulate_today
|
|
185
|
+
|
|
186
|
+
expect(acc.activities.count_by_type[:interest]).to eq 1
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
describe "#tomorrow" do
|
|
191
|
+
it "advances the date and simulates" do
|
|
192
|
+
acc = setup_accountant
|
|
193
|
+
acc.activity(:issue, Date.current, "1000.00".to_d)
|
|
194
|
+
|
|
195
|
+
original_date = acc.date
|
|
196
|
+
acc.tomorrow
|
|
197
|
+
|
|
198
|
+
expect(acc.date).to eq original_date + 1
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
describe "#balances" do
|
|
203
|
+
it "returns named balance sums from the default book" do
|
|
204
|
+
acc = setup_accountant
|
|
205
|
+
acc.activity(:issue, Date.current, "500.00".to_d)
|
|
206
|
+
|
|
207
|
+
expect(acc.balances[:accruing]).to eq "500.00".to_d
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
describe "#schedule=" do
|
|
212
|
+
it "accepts a Schedule" do
|
|
213
|
+
acc = TestAccountant.new
|
|
214
|
+
schedule = Morty::Schedule.new(acc, [{ amount: 100.to_d, date: Date.current, type: :payment }])
|
|
215
|
+
acc.schedule = schedule
|
|
216
|
+
expect(acc.schedule).to be_a(Morty::Schedule)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "wraps an array in a Schedule" do
|
|
220
|
+
acc = TestAccountant.new
|
|
221
|
+
acc.schedule = [{ amount: 100.to_d, date: Date.current, type: :payment }]
|
|
222
|
+
expect(acc.schedule).to be_a(Morty::Schedule)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
describe "#apply" do
|
|
227
|
+
it "applies activities to books and adds to activity list" do
|
|
228
|
+
acc = setup_accountant
|
|
229
|
+
activity = create_activity_with_entries!(source_id: source.id, amount: "200.00".to_d)
|
|
230
|
+
acc.apply(activity)
|
|
231
|
+
|
|
232
|
+
expect(acc.activities.size).to eq 1
|
|
233
|
+
expect(acc.accounts[:cash]).to eq "200.00".to_d
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe Morty::Book do
|
|
4
|
+
# Minimal accountant-like object for Book specs
|
|
5
|
+
let(:accounts_hash) { Hash.new { |h, k| h[k] = 0.to_d } }
|
|
6
|
+
let(:accountant) do
|
|
7
|
+
double("accountant",
|
|
8
|
+
accounts: accounts_hash,
|
|
9
|
+
balances_list: { default: { total: [:cash, :principal] } }
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe "initialization" do
|
|
14
|
+
it "raises when ledger is nil" do
|
|
15
|
+
expect { described_class.new(nil, accountant: accountant) }.to raise_error("missing ledger")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "succeeds with a ledger" do
|
|
19
|
+
book = described_class.new(:default, accountant: accountant)
|
|
20
|
+
expect(book.ledger).to eq :default
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe "#entry" do
|
|
25
|
+
let(:book) { described_class.new(:default, accountant: accountant) }
|
|
26
|
+
let(:activity) { build_activity }
|
|
27
|
+
|
|
28
|
+
it "with positive amount creates and applies an entry" do
|
|
29
|
+
entry = book.entry(:cash, :principal, "100.00".to_d, activity: activity)
|
|
30
|
+
|
|
31
|
+
expect(entry).to be_a(Morty::Entry)
|
|
32
|
+
expect(entry.amount).to eq "100.00".to_d
|
|
33
|
+
expect(accounts_hash[:cash]).to eq "100.00".to_d
|
|
34
|
+
expect(accounts_hash[:principal]).to eq "-100.00".to_d
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "with zero amount returns nil" do
|
|
38
|
+
expect(book.entry(:cash, :principal, 0, activity: activity)).to be_nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "with nil amount returns nil" do
|
|
42
|
+
expect(book.entry(:cash, :principal, nil, activity: activity)).to be_nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "with negative amount raises" do
|
|
46
|
+
expect {
|
|
47
|
+
book.entry(:cash, :principal, "-5.00".to_d, activity: activity)
|
|
48
|
+
}.to raise_error("entry amount cannot be negative")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "with nonexistent account pair raises (nil EntryType)" do
|
|
52
|
+
expect {
|
|
53
|
+
book.entry(:cash, :cash, "10.00".to_d, activity: activity)
|
|
54
|
+
}.to raise_error(NoMethodError)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe "#apply" do
|
|
59
|
+
let(:book) { described_class.new(:default, accountant: accountant) }
|
|
60
|
+
|
|
61
|
+
it "applies an Entry to accounts" do
|
|
62
|
+
entry_type = Morty::EntryType.find_by_accounts(:cash, :principal)
|
|
63
|
+
entry = Morty::Entry.new(entry_type: entry_type, amount: "50.00".to_d)
|
|
64
|
+
|
|
65
|
+
book.apply(entry)
|
|
66
|
+
|
|
67
|
+
expect(accounts_hash[:cash]).to eq "50.00".to_d
|
|
68
|
+
expect(accounts_hash[:principal]).to eq "-50.00".to_d
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "applies an Activity's entries matching the book's ledger" do
|
|
72
|
+
activity = create_activity_with_entries!(amount: "75.00".to_d)
|
|
73
|
+
|
|
74
|
+
book.apply(activity)
|
|
75
|
+
|
|
76
|
+
expect(accounts_hash[:cash]).to eq "75.00".to_d
|
|
77
|
+
expect(accounts_hash[:principal]).to eq "-75.00".to_d
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe "#balances" do
|
|
82
|
+
let(:book) { described_class.new(:default, accountant: accountant) }
|
|
83
|
+
|
|
84
|
+
it "returns named balance sums" do
|
|
85
|
+
accounts_hash[:cash] = "100.00".to_d
|
|
86
|
+
accounts_hash[:principal] = "-50.00".to_d
|
|
87
|
+
|
|
88
|
+
expect(book.balances[:total]).to eq "50.00".to_d
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe Morty::Diff do
|
|
4
|
+
describe Morty::Diff::Sum do
|
|
5
|
+
let(:entry_type) { Morty::EntryType.find_by_accounts(:cash, :principal) }
|
|
6
|
+
|
|
7
|
+
def activity_with_entry(amount)
|
|
8
|
+
activity = build_activity(amount: amount)
|
|
9
|
+
activity.entries.build(entry_type: entry_type, amount: amount)
|
|
10
|
+
activity
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe "#calculate" do
|
|
14
|
+
it "aggregates entries by type" do
|
|
15
|
+
activities = [activity_with_entry("100.00".to_d), activity_with_entry("50.00".to_d)]
|
|
16
|
+
sum = described_class.new(activities)
|
|
17
|
+
result = sum.calculate
|
|
18
|
+
|
|
19
|
+
expect(result[entry_type]).to eq "150.00".to_d
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "is memoized" do
|
|
23
|
+
sum = described_class.new([activity_with_entry("10.00".to_d)])
|
|
24
|
+
expect(sum.calculate).to equal(sum.calculate)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe "#-" do
|
|
29
|
+
it "subtracts another Sum producing net differences" do
|
|
30
|
+
a = described_class.new([activity_with_entry("100.00".to_d)])
|
|
31
|
+
b = described_class.new([activity_with_entry("60.00".to_d)])
|
|
32
|
+
|
|
33
|
+
result = a - b
|
|
34
|
+
|
|
35
|
+
expect(result[entry_type]).to eq "40.00".to_d
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "produces inverse type when result is negative" do
|
|
39
|
+
a = described_class.new([activity_with_entry("30.00".to_d)])
|
|
40
|
+
b = described_class.new([activity_with_entry("80.00".to_d)])
|
|
41
|
+
|
|
42
|
+
result = a - b
|
|
43
|
+
inverse = entry_type.inverse
|
|
44
|
+
|
|
45
|
+
expect(result[inverse]).to eq "50.00".to_d
|
|
46
|
+
expect(result).not_to have_key(entry_type)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe "#reduce" do
|
|
51
|
+
it "removes zero entries" do
|
|
52
|
+
sum = described_class.new([])
|
|
53
|
+
result = sum.reduce({ entry_type => 0.to_d })
|
|
54
|
+
expect(result).to be_empty
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe "Diff" do
|
|
60
|
+
def make_accountant_stub(activities)
|
|
61
|
+
list = Morty::List::Activity.new(activities)
|
|
62
|
+
double("accountant", activities: list)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe "#original?" do
|
|
66
|
+
it "returns true for activities in the original set" do
|
|
67
|
+
activity = build_activity
|
|
68
|
+
original = make_accountant_stub([activity])
|
|
69
|
+
adjusted = make_accountant_stub([activity])
|
|
70
|
+
|
|
71
|
+
diff = described_class.new(original, adjusted)
|
|
72
|
+
expect(diff.original?(activity)).to be true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "returns false for activities not in the original" do
|
|
76
|
+
a = build_activity(type: :issue)
|
|
77
|
+
b = build_activity(type: :payment)
|
|
78
|
+
original = make_accountant_stub([a])
|
|
79
|
+
adjusted = make_accountant_stub([b])
|
|
80
|
+
|
|
81
|
+
diff = described_class.new(original, adjusted)
|
|
82
|
+
expect(diff.original?(b)).to be false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe "#additional" do
|
|
87
|
+
it "returns adjusted activities not in original, excluding interest" do
|
|
88
|
+
original_activity = build_activity(type: :issue)
|
|
89
|
+
new_activity = build_activity(type: :payment)
|
|
90
|
+
interest_activity = build_activity(type: :interest)
|
|
91
|
+
|
|
92
|
+
original = make_accountant_stub([original_activity])
|
|
93
|
+
adjusted = make_accountant_stub([original_activity, new_activity, interest_activity])
|
|
94
|
+
|
|
95
|
+
diff = described_class.new(original, adjusted)
|
|
96
|
+
additional = diff.additional
|
|
97
|
+
|
|
98
|
+
expect(additional.map(&:type)).to eq [:payment]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe Morty::Event do
|
|
4
|
+
describe "construction from Hash" do
|
|
5
|
+
subject { described_class.new(amount: 100.to_d, date: Date.new(2026, 1, 1), type: :issue) }
|
|
6
|
+
|
|
7
|
+
its(:amount) { is_expected.to eq 100.to_d }
|
|
8
|
+
its(:date) { is_expected.to eq Date.new(2026, 1, 1) }
|
|
9
|
+
its(:type) { is_expected.to eq :issue }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe "construction from Hash with missing keys" do
|
|
13
|
+
subject { described_class.new({}) }
|
|
14
|
+
|
|
15
|
+
its(:amount) { is_expected.to be_nil }
|
|
16
|
+
its(:date) { is_expected.to be_nil }
|
|
17
|
+
its(:type) { is_expected.to be_nil }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe "construction from another Event" do
|
|
21
|
+
let(:original) { described_class.new(amount: 50.to_d, date: Date.current, type: :payment) }
|
|
22
|
+
subject { described_class.new(original) }
|
|
23
|
+
|
|
24
|
+
it "shares the same info hash (alias, not copy)" do
|
|
25
|
+
expect(subject.info).to equal(original.info)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
its(:amount) { is_expected.to eq 50.to_d }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe "construction from an Activity" do
|
|
32
|
+
let(:activity) { build_activity(type: :issue, amount: "200.00".to_d) }
|
|
33
|
+
subject { described_class.new(activity) }
|
|
34
|
+
|
|
35
|
+
its(:amount) { is_expected.to eq "200.00".to_d }
|
|
36
|
+
its(:type) { is_expected.to eq :issue }
|
|
37
|
+
its(:date) { is_expected.to eq activity.effective_date }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe "invalid construction" do
|
|
41
|
+
it "raises Morty::Error for a String" do
|
|
42
|
+
expect { described_class.new("bad") }.to raise_error(Morty::Error)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "raises Morty::Error for an Integer" do
|
|
46
|
+
expect { described_class.new(42) }.to raise_error(Morty::Error)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "raises Morty::Error for nil" do
|
|
50
|
+
expect { described_class.new(nil) }.to raise_error(Morty::Error)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|