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
@@ -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,3 @@
1
+ <svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
2
+ <circle cx="256" cy="256" r="256" fill="red"/>
3
+ </svg>
@@ -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