generalis 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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +68 -0
  3. data/.gitignore +11 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +46 -0
  6. data/.ruby-version +1 -0
  7. data/.vscode/settings.json +5 -0
  8. data/Gemfile +13 -0
  9. data/Gemfile.lock +134 -0
  10. data/README.md +622 -0
  11. data/Rakefile +12 -0
  12. data/bin/console +15 -0
  13. data/bin/rspec +29 -0
  14. data/bin/rubocop +29 -0
  15. data/bin/setup +8 -0
  16. data/generalis.gemspec +34 -0
  17. data/lib/generalis/account.rb +98 -0
  18. data/lib/generalis/accountable.rb +43 -0
  19. data/lib/generalis/asset.rb +7 -0
  20. data/lib/generalis/config.rb +15 -0
  21. data/lib/generalis/credit.rb +7 -0
  22. data/lib/generalis/debit.rb +7 -0
  23. data/lib/generalis/entry.rb +66 -0
  24. data/lib/generalis/expense.rb +7 -0
  25. data/lib/generalis/liability.rb +7 -0
  26. data/lib/generalis/link.rb +10 -0
  27. data/lib/generalis/linkable.rb +15 -0
  28. data/lib/generalis/revenue.rb +7 -0
  29. data/lib/generalis/rspec.rb +6 -0
  30. data/lib/generalis/transaction/double_entry.rb +37 -0
  31. data/lib/generalis/transaction/dsl.rb +70 -0
  32. data/lib/generalis/transaction/links.rb +29 -0
  33. data/lib/generalis/transaction/preparation.rb +45 -0
  34. data/lib/generalis/transaction.rb +109 -0
  35. data/lib/generalis/version.rb +5 -0
  36. data/lib/generalis.rb +55 -0
  37. data/lib/generators/factory_bot/templates/transactions.rb.erb +7 -0
  38. data/lib/generators/factory_bot/transaction_generator.rb +39 -0
  39. data/lib/generators/generalis/install_generator.rb +19 -0
  40. data/lib/generators/generalis/migrations_generator.rb +45 -0
  41. data/lib/generators/generalis/templates/base_transaction.rb +12 -0
  42. data/lib/generators/generalis/templates/create_ledger_accounts.rb.erb +18 -0
  43. data/lib/generators/generalis/templates/create_ledger_entries.rb.erb +25 -0
  44. data/lib/generators/generalis/templates/create_ledger_links.rb.erb +14 -0
  45. data/lib/generators/generalis/templates/create_ledger_transactions.rb.erb +14 -0
  46. data/lib/generators/generalis/templates/generalis.rb +6 -0
  47. data/lib/generators/generalis/templates/transaction.rb.erb +26 -0
  48. data/lib/generators/generalis/transaction_generator.rb +33 -0
  49. data/lib/generators/rspec/templates/transaction_spec.rb.erb +11 -0
  50. data/lib/generators/rspec/transaction_generator.rb +41 -0
  51. data/lib/rspec/change_balance_of_matcher.rb +32 -0
  52. data/lib/rspec/credit_account_matcher.rb +45 -0
  53. data/lib/rspec/debit_account_matcher.rb +45 -0
  54. data/lib/rspec/have_balance_matcher.rb +27 -0
  55. data/lib/rspec/helpers/format_helper.rb +28 -0
  56. data/lib/rspec/helpers/resolve_account_helper.rb +21 -0
  57. data/lib/rspec/helpers/resolve_amount_helper.rb +21 -0
  58. metadata +136 -0
data/README.md ADDED
@@ -0,0 +1,622 @@
1
+ # Generalis
2
+
3
+ Generalis is a financial general ledger for ActiveRecord.
4
+ It incorporates a light DSL for defining ledger transactions and connecting financial records to your existing models, built-in currency support, and RSpec integrations.
5
+
6
+ If DSLs are not to your liking, Generalis also provides support for [ad-hoc transactions](#ad-hoc-transactions) that behave more like plain-old ActiveRecord models.
7
+
8
+ Generalis currently only supports and is tested against PostgreSQL, but support for other database systems is planned.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'generalis'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ $ bundle install
21
+
22
+ To generate the classes and configuration used by Generalis, run:
23
+
24
+ $ bin/rails generate generalis:install
25
+
26
+ To generate the migration files for the supporting database tables, run:
27
+
28
+ $ bin/rails generate generalis:migrations
29
+
30
+ And then run:
31
+
32
+ $ bin/rails db:migrate
33
+
34
+ ### MoneyRails Integration
35
+
36
+ Generalis relies on MoneyRails to operate and handle currencies correctly. It's not necessary to add it to your Gemfile directly, however, it is necessary to generate the configuration initializer:
37
+
38
+ https://github.com/RubyMoney/money-rails#installation
39
+
40
+ ## Ledger Accounts
41
+
42
+ Generalis includes 4 of the most common major account types:
43
+
44
+ | Account Type | Balance Behaviour |
45
+ | ------------ | ----------------- |
46
+ | Asset | Debit Normal |
47
+ | Expense | Debit Normal |
48
+ | Liability | Credit Normal |
49
+ | Revenue | Credit Normal |
50
+
51
+ These account types can be accessed as follows:
52
+ - `Generalis::Asset`
53
+ - `Generalis::Expense`
54
+ - `Generalis::Liability`
55
+ - `Generalis::Revenue`
56
+
57
+ Additional account types can be defined if necessary. (See the section on [custom account types](#custom-account-types).)
58
+
59
+ Ledger accounts can be either global or associated to a particular record. The differences between the to mechanisms is explained in detail below.
60
+
61
+ ### Global Accounts
62
+
63
+ Global ledger accounts are typically those that are associated with your own business.
64
+ For example, your company's own cash, revenue, and expenses would typically be global accounts in Generalis.
65
+
66
+ For accounts which pertain to a particular client or customer (like owed balance or store credits), see the section on [Associated Accounts](#associated-accounts).
67
+
68
+ #### Defining Global Accounts
69
+
70
+ Global accounts can be created with the `define(...)` helper, which will automatically create the account if it doesn't already exist:
71
+
72
+ ```ruby
73
+ Generalis::Asset.define(:cash)
74
+ ```
75
+
76
+ Global accounts are unique based on their name, so only one global account (of any type) can exist with a given name.
77
+ It's typical practice to define your global accounts ahead of time, as a seed.
78
+
79
+ #### Retrieving Global Accounts
80
+
81
+ Global accounts can be retrieved by their name using either `[]` index notation or by using the `.lookup()` helper method:
82
+
83
+ ```ruby
84
+ cash = Generalis::Asset[:cash]
85
+
86
+ # OR
87
+
88
+ cash = Generalis::Asset.lookup(:cash)
89
+ ```
90
+
91
+ Both methods above will raise an `ActiveRecord::RecordNotFound` error if the requested account does not exist.
92
+
93
+ Generalis accounts are just plain-old ActiveRecord objects, so it's also possible to use all the normal query methods like `.where(...)` and `.find_by(...)`.
94
+
95
+ ### Associated Accounts
96
+
97
+ Associated ledger accounts are used to represent balances that belong to a particular client, customer, or some other record in your system. For example, a balance owed by a particular customer or a store credit issued to a customer would be modeled by an associated account.
98
+
99
+ When using associated accounts, multiple ledger account records may share the same name, but will be uniquely distinguished by their `owner` association.
100
+ The balances each of these owner records is therefore tracked separately.
101
+
102
+ #### Defining Associated Accounts
103
+
104
+ Generalis provides an `Accountable` concern that should be included into your application's ActiveRecord models to automatically associate ledger accounts.
105
+
106
+ For example, to create an Asset account called "accounts_receivable" for a customer model, use:
107
+
108
+ ```ruby
109
+ class Customer < ApplicationRecord
110
+ include Generalis::Accountable
111
+
112
+ has_asset_account :accounts_receivable
113
+ end
114
+ ```
115
+
116
+ DSL macros are available for all four types of supported accounts:
117
+
118
+ ```ruby
119
+ has_asset_account :name
120
+ has_expense_account :name
121
+ has_liability_account :name
122
+ has_revenue_account :name
123
+ ```
124
+
125
+ The associated account can then be accessed just like any standard association:
126
+
127
+ ```ruby
128
+ customer = Customer.create!(...)
129
+
130
+ customer.accounts_receivable # => #<Generalis::Asset:0x0000... >
131
+ ```
132
+
133
+ It's also possible to access the account using the same helpers methods as global accounts, by specifying the owner record:
134
+
135
+ ```ruby
136
+ customer = Customer.create!(...)
137
+
138
+ Generalis::Asset[:accounts_receivable, owner: customer] # => #<Generalis::Asset:0x0000... >
139
+ ```
140
+
141
+ #### Manual Account Creation
142
+
143
+ By default, associated accounts are created automatically together with the accountable record. It's possible to disable this behaviour using:
144
+
145
+ ```ruby
146
+ has_asset_account :accounts_receivable, auto_create: false
147
+ ```
148
+
149
+ The associated account can be created later using a built-in helper:
150
+
151
+ ```ruby
152
+ customer = Customer.create!(...)
153
+
154
+ customer.create_accounts_receivable # => #<Generalis::Asset:0x00000... >
155
+ ```
156
+
157
+ To locate records that are missing an associated account, a scope is automatically provided:
158
+
159
+ ```ruby
160
+ customers_missing_accounts = Customer.without_accounts_receivable
161
+ ```
162
+
163
+ #### Dependent Account Behaviour
164
+
165
+ By default, associated accounts are treated as `dependent: :restrict_with_error`. This means that trying to delete a record with associated accounts will be prevented and ActiveModel::Errors will be set.
166
+
167
+ This behaviour can be changed using:
168
+
169
+ ```ruby
170
+ has_asset_account :accounts_receivable, dependent: :destroy
171
+ ```
172
+
173
+ NOTE: Accounts with associated ledger entries cannot be deleted as doing so would interfere with the state of the ledger. One possible option to circumvent this limitation would be to leave an orphaned record for the account.
174
+
175
+ This can be done with:
176
+
177
+ ```ruby
178
+ has_asset_account :accounts_receivable, dependent: false
179
+ ```
180
+
181
+ ### Balances and Currency Support
182
+
183
+ Generalis has first-class support for currency built-in, however, it doesn't perform any automatic exchange or normalization.
184
+
185
+ Instead, each currency is stored as a separate balance on the account, for example:
186
+
187
+ ```ruby
188
+ cash = Generalis::Asset[:cash]
189
+
190
+ cash.balance('CAD') # => #<Money $100.00>
191
+ cash.balance('USD') # => #<Money $0.00>
192
+ cash.balance('EUR') # => #<Money €25.00>
193
+ ```
194
+
195
+ Requesting the balance of a currency that does not appear on an account will return 0 (as a Money object).
196
+
197
+ It's also possible to request a summary of all balances on an account:
198
+
199
+ ```ruby
200
+ cash.balances # => {"CAD"=>#<Money $100.00>,"EUR"=>#<Money €25.00>}
201
+ ```
202
+
203
+ ### Custom Account Types
204
+
205
+ Generalis allows additional account types to be defined if necessary. For example, if you wished to define an equity account type, you would add the following model to your application:
206
+
207
+ ```ruby
208
+ class Equity < Generalis::Account
209
+ balance_type :credit_normal
210
+ end
211
+ ```
212
+
213
+ The `balance_type` macro defines the behaviour of the balance when credited or debited an amount. The supported modes are:
214
+ - `:debit_normal` (like Asset or Expense accounts)
215
+ - `:credit_normal` (like Liability or Revenue accounts)
216
+
217
+ Alternatively, if you'd prefer to keep naming consistent with the built-in account types, you can instead define your account in an initializer:
218
+
219
+ ```ruby
220
+ module Generalis
221
+ class Equity < Account
222
+ balance_type :credit_normal
223
+ end
224
+ end
225
+ ```
226
+
227
+ ## Ledger Transactions
228
+
229
+ Ledger transactions are a record of an event or action in the system that impacted the ledger. They are made up of a collection of ledger entries which occurred together.
230
+
231
+ Writing to the ledger is accomplished by creating a Transaction record, with the associated credit and debit entries applying changes to the balances of their corresponding accounts.
232
+
233
+ For a Transaction to be valid, the credit and debit entries included in the transaction must balance. This means that the sum of all credits must equal the sum of all debits (per currency).
234
+ This is a best-effort constraint is enforced by a validation on the transaction model, as well as by marking key attributes on persisted ledger entries as read-only.
235
+ Generalis is not able to prevent validations from being disabled or removed, nor existing data from being modified directly in the database. For more information, see the [data integrity](#data-integrity) section.
236
+
237
+ Transactions also store additional information to describe the changes made to the ledger:
238
+
239
+ | Field | Type | Usage |
240
+ | -------------- | ------------- | ----- |
241
+ | type | String | An optional field used for Rails' Single-Table Inheritance functionality. |
242
+ | transaction_id | String | A unique key for the transaction, intended to prevent duplicate operations, typically human-readable. |
243
+ | description | String | An optional message describing the event or action that caused this transaction. |
244
+ | occurred_at | Time | An optional timestamp indicating when the event or action occurred that trigger this transaction. |
245
+ | metadata | Hash or Array | An optional JSON field used to store application-specific information. Can be used with [`store_accessor`](https://api.rubyonrails.org/classes/ActiveRecord/Store.html) to define custom attributes. |
246
+
247
+ **NOTE:** In most cases, `metadata` should not be used to store relationships to other records. Instead, the [linked records](#linked-records) mechanism should be used.
248
+
249
+ ### Transaction DSL
250
+
251
+ To create a transaction model, run the generator:
252
+
253
+ $ bin/rails generate generalis:transaction Example
254
+
255
+ This will generate a new transaction model in your `app/models/ledger` directory, which contains a stub ledger transaction:
256
+
257
+ ```ruby
258
+ # frozen_string_literal: true
259
+
260
+ class Ledger::ExampleTransaction < Ledger::BaseTransaction
261
+ transaction_id do
262
+ # TODO: Generate a transaction ID
263
+ end
264
+
265
+ description do
266
+ # Optional: Provide a description of the transaction
267
+ end
268
+
269
+ occurred_at do
270
+ # Optional: Include a timestamp for the transaction (defaults to now)
271
+ end
272
+
273
+ metadata do
274
+ # Optional: Any additional metadata to be stored with the transaction (an Array or Hash)
275
+ end
276
+
277
+ double_entry do |e|
278
+ # TODO: Define entries
279
+ # e.debit = Generalis::Asset[:cash]
280
+ # e.credit = customer.accounts_receivable
281
+ # e.amount = 100.00
282
+ end
283
+ end
284
+ ```
285
+
286
+ The `transaction_id`, `description`, `occurred_at`, and `metadata` DSL macros are used to automatically set their corresponding fields on the constructed Transaction. The functions of these fields is described in the table [here](#ledger-transactions).
287
+
288
+ Transactions behave like ActiveRecord models, so they can be built and saved as you would any other model in your application:
289
+
290
+ ```ruby
291
+ transaction = Ledger::ExampleTransaction.new
292
+
293
+ if transaction.save
294
+ # All good!
295
+ else
296
+ puts transaction.errors
297
+ end
298
+ ```
299
+
300
+ **NOTE:** Beware of potential naming collisions between `transaction` and some built-in ActiveRecord methods. If creating a `belongs_to` or `has_one` association to a Transaction, you will need to name the association `ledger_transaction` or similar to avoid overwriting the built-in methods.
301
+
302
+ #### Linked Records
303
+
304
+ Generalis allows ActiveRecord models to be associated with transaction classes:
305
+
306
+ ```ruby
307
+ class Ledger::ExampleTransaction < Ledger::BaseTransaction
308
+ has_one_linked :charge
309
+ end
310
+ ```
311
+
312
+ Linked records are managed through a polymorphic join-table (handled by the `Generalis::Link` model), so any model can be associated to a transaction without requiring a database migration.
313
+
314
+ Linked records behave like a standard Rails association, and can be assigned as normal:
315
+
316
+ ```ruby
317
+ transaction = Ledger::ExampleTransaction.new
318
+ transaction.charge = Charge.find(...)
319
+
320
+ # OR
321
+
322
+ charge = Charge.find(...)
323
+ transaction = Ledger::ExampleTransaction.new(charge: charge)
324
+ ```
325
+
326
+ In cases where the name of the association does not match the name of the class, it's possible to specify the class name explicitly:
327
+
328
+ ```ruby
329
+ has_one_linked :charge, class_name: 'Card::Charge'
330
+ ```
331
+
332
+ Has-many style associations are also supported in the same way:
333
+
334
+ ```ruby
335
+ has_many_linked :fees
336
+ ```
337
+
338
+ To add inverse associations to your link records, include the `Linkable` concern:
339
+
340
+ ```ruby
341
+ class Charge < ApplicationRecord
342
+ include Generalis::Linkable
343
+ end
344
+ ```
345
+
346
+ This will add an association that allows access to any linked transactions:
347
+
348
+ ```ruby
349
+ charge = Charge.find(...)
350
+
351
+ charge.linked_ledger_transactions # => [ ... ]
352
+ ```
353
+
354
+ #### Double-Entry Notation
355
+
356
+ When using the default double-entry notation, a debit and credit entry are defined together with a shared amount. These two entries are also linked together by a common key (called a `pair_id`) so that they be retrieved together.
357
+
358
+ If you are already using MoneyRails and store amounts as Money objects, these can be assigned directly to the constructed entries:
359
+
360
+ ```ruby
361
+ double_entry do |e|
362
+ e.debit = Generalis::Asset[:cash]
363
+ e.credit = customer.accounts_receivable
364
+ e.amount = charge.amount
365
+ end
366
+ ```
367
+
368
+ If your application does not use Money objects, the amount and currency must be specified explicitly. This is done by assigning values to the `amount` and `currency` fields on the entry builder:
369
+
370
+ ```ruby
371
+ e.amount = 100.00
372
+ e.currency = 'CAD'
373
+ ```
374
+
375
+ If your application stores money as an integer number of cents, the `amount_cents` field can be assigned instead:
376
+
377
+ ```ruby
378
+ e.amount_cents = 100_00
379
+ e.currency = 'CAD'
380
+ ```
381
+
382
+ Regardless of which mechanism is used, Generalis internally will store these amounts as Money objects.
383
+
384
+ #### Manual Credit/Debit Notation
385
+
386
+ Generalis also provides an alternative to the double-entry notation where credit and debit entries may be separately defined in the DSL:
387
+
388
+ ```ruby
389
+ credit do |e|
390
+ e.account = Generalis::Asset[:cash]
391
+ e.amount = 100.00
392
+ end
393
+
394
+ debit do |e|
395
+ e.account = customer.accounts_receivable
396
+ e.amount = 100.00
397
+ end
398
+ ```
399
+
400
+ When using this notation, debit and credit entries will not be linked together as a pair (although you can still manually assign a `pair_id`). However, the credited and debited amounts (per currency) must still be equal.
401
+
402
+ This notation also allows for transactions that have non-equal numbers of credits and debits, provided that their total amounts sum up to be equal. For example:
403
+
404
+ ```ruby
405
+ credit do |e|
406
+ e.account = Generalis::Asset[:cash]
407
+ e.amount = 90.00
408
+ end
409
+
410
+ credit do |e|
411
+ e.account = Generalis::Asset[:holding]
412
+ e.amount = 10.00
413
+ end
414
+
415
+ debit do |e|
416
+ e.account = accounts.accounts_receivable
417
+ e.amount = 100.00
418
+ end
419
+ ```
420
+
421
+ This operation would be considered valid, as both the sum of credits and debits equal to $100.00.
422
+
423
+ ### Ad-Hoc Transactions
424
+
425
+ Generalis also supports creating transactions without using the DSL by directly using the built-in Transaction model. We refer to these as ad-hoc transactions, and they can be useful in cases where there is significant branching in transaction logic, or where defining a transaction class is otherwise not possible.
426
+
427
+ An example ad-hoc transaction might look like:
428
+
429
+ ```ruby
430
+ transaction = Generalis::Transaction.new
431
+
432
+ transaction.transaction_id = "charge-#{charge.id}"
433
+ transaction.description = "Customer #{customer.id} charge for #{charge.amount}"
434
+
435
+ # Define the credits and debits that are involved in the transaction.
436
+ transaction.add_credit(account: Generalis::Asset[:cash], amount: charge.amount)
437
+ transaction.add_debit(account: customer.accounts_receivable, amount: charge.amount)
438
+
439
+ # Add a linked record for future reference.
440
+ transaction.add_link(:charge, charge)
441
+
442
+ transaction.save!
443
+ ```
444
+
445
+ ## Data Integrity
446
+
447
+ Generalis includes several mechanisms that are intended to ensure correctness and integrity of the ledger state and balances. These include validations on the Transaction model, attributes being marked read-only on the ledger Entry model, and automatic locking for ledger Accounts included in a Transaction.
448
+
449
+ However, it should be noted that these are all best-effort mechanisms and they are not able to catch or prevent all efforts to tamper with the data.
450
+ Validations can be disabled or removed, read-only constraints can be ignored by queries made directly in the database.
451
+
452
+ For this reason, a number of tools are provided to assist with prevent and catching these issues if they occur.
453
+
454
+ ### Verifying Balances
455
+
456
+ The most important point of integrity that Generalis is concerned with is ensuring that the ledger balances. This is traditionally verified using the Balance Sheet Equation, which often takes the form of something like:
457
+
458
+ ```
459
+ Assets + Expenses = Liabilities + Revenues + Equity
460
+ ```
461
+
462
+ Generalis generalizes this formula to the following constraint:
463
+
464
+ ```
465
+ SUM(Debit-Normal Accounts) - SUM(Credit-Normal Accounts) = 0
466
+ ```
467
+
468
+ This condition can then be verified with the `trial_balances` helper method:
469
+
470
+ ```ruby
471
+ Generalis.trial_balances # => {"CAD"=>0,"USD"=>0,"EUR"=>0}
472
+ ```
473
+
474
+ Provided that the balance for each currency sums to zero, the ledger balances for that currency. Any non-zero value indicates that the is error in the ledger.
475
+
476
+ ### Locating Problematic Transactions
477
+
478
+ If a balance issue has been identified, it's important to locate which transactions are causing the issue. Generalis provides a scope to locate any transactions which do not themselves balance:
479
+
480
+ ```ruby
481
+ Generalis::Transaction.imbalanced # => [...]
482
+ ```
483
+
484
+ ### Accounts and Locking
485
+
486
+ Generalis automatically handles locking accounts involved in a transaction for the purposes of calculating their balances after the transaction.
487
+ However, it is important to note that these locks are acquired _after_ the ledger entries have been prepared by the DSL.
488
+
489
+ This means that if the balances of the accounts are used as part of the `amount` of the ledger entry, there is a potential race condition with other transactions that may modify the balance of that account.
490
+
491
+ As an example, consider this transaction which exchanges a customer's store credit from CAD to USD:
492
+
493
+ ```ruby
494
+ class Ledger::ExchangeStoreCreditTransaction < Ledger::BaseTransaction
495
+ # ...
496
+
497
+ double_entry do |e|
498
+ e.debit = Generalis::Asset[:cash]
499
+ e.credit = customer.store_credit
500
+ e.amount = customer.store_credit.balance('CAD')
501
+ end
502
+
503
+ double_entry do |e|
504
+ e.debit = customer.store_credit
505
+ e.credit = Generalis::Asset[:cash]
506
+ e.amount = customer.store_credit.balance('CAD').exchange_to('USD')
507
+ end
508
+ end
509
+ ```
510
+
511
+ It is possible for another transaction to have modified the balance of the `store_credit` account between when the transaction was prepared and when the locks would be acquired to calculate its final balances.
512
+
513
+ One approach to mitigate this is to acquire locks on the involved accounts ahead of time using a `before_prepare` hook and the `lock_for_account_balance` helper method:
514
+
515
+ ```ruby
516
+ before_prepare do
517
+ Generalis::Account.lock_for_account_balance(
518
+ customer.store_credit,
519
+ Generalis::Asset[:cash]
520
+ )
521
+ end
522
+ ```
523
+
524
+ **NOTE:** To avoid the risk of deadlocks between transactions, it is important to include _all_ accounts involved in a transaction when acquiring locks.
525
+
526
+ ## RSpec Matchers
527
+
528
+ Generalis includes a number of RSpec matchers to help with testing ledger transactions. To use them, add this to your `rails_helper.rb` file:
529
+
530
+ ```ruby
531
+ require 'generalis/rspec'
532
+ ```
533
+
534
+ ### Credit/Debit Account Matchers
535
+
536
+ When testing transactions, it may be helpful to verify that a particular amount has been credited or debited towards a particular account. For this purpose, the `credit_account` and `debit_account` matchers:
537
+
538
+ ```ruby
539
+ let(:charge) { create(:charge) }
540
+ let(:customer) { charge.customer }
541
+
542
+ it 'credits the charge amount to the cash account' do
543
+ expect(transaction).to credit_account(:cash).with_amount(charge.amount)
544
+ end
545
+
546
+ it "debits the charge amount to the customer's receivable account" do
547
+ expect(transaction).to debit_account(customer.accounts_receivable).with_amount(charge.amount)
548
+ end
549
+ ```
550
+
551
+ Accounts may be specified either by a name (for global accounts) or by an instance of the account object.
552
+ The amount may be specified by a Money object or by a numeric value and a currency:
553
+
554
+ ```ruby
555
+ expect(transaction).to credit_account(:cash).with_amount(100.00, 'CAD')
556
+ ```
557
+
558
+ These matchers are currency aware, and will only consider the currency that is specified in the amount.
559
+
560
+ **NOTE:** These matchers will sum together all credits or debits that were made towards the same account. It is not necessary for the transaction to be persisted to use this matcher.
561
+
562
+ ### Change Balance of Account Matcher
563
+
564
+ When testing transactions, it may also be useful to set expectations of what the net-change to an account balance will be after any credits or debits have been applied towards the account. To do so, the `change_balance_of` matcher is provided:
565
+
566
+ ```ruby
567
+ it 'increases the balance of the cash account by the charge amount' do
568
+ expect(transaction).to change_balance_of(:cash).by(charge.amount)
569
+ end
570
+
571
+ it 'does not change the balance of the orders revenue account' do
572
+ expect(transaction).not_to change_balance_of(:orders)
573
+ end
574
+ ```
575
+
576
+ Accounts may be specified either by a name (for global accounts) or by an instance of the account object.
577
+ The amount may be specified by a Money object or by a numeric value and a currency:
578
+
579
+ ```ruby
580
+ expect(transaction).to change_balance_of(:cash).by(100.00, 'CAD')
581
+ ```
582
+
583
+ This matcher currency aware, and will only consider the currency that is specified in the amount.
584
+
585
+ ### Have Balance Matcher
586
+
587
+ For testing integration between parts of the system and verifying financial flows, the `have_balance` matcher is recommended.
588
+
589
+ ```ruby
590
+ let(:order) { create(:order) }
591
+ let(:customer) { order.customer }
592
+
593
+ it "adds the total of the order to the customer's receivable balance after checkout" do
594
+ order.checkout!
595
+ expect(customer.accounts_receivable).to have_balance(order.total)
596
+ end
597
+ ```
598
+
599
+ Unlike the `debit_account` and `credit_account` matchers which validate a transaction, the `have_balance` matcher tests the balance of a ledger account.
600
+
601
+ ### Examples
602
+
603
+ Examples of the included RSpec matches being used can be found in the integration test-suite directory, [here](./integration/spec/models/ledger).
604
+
605
+ ## Future Features and Wishlist
606
+
607
+ Generalis can be better! There's features and helpful tools that we want to build in the future but haven't gotten around to yet. Some of them are:
608
+
609
+ - [ ] Better install process for MoneyRails
610
+ - [ ] More documentation for ledger Entry records
611
+ - [ ] Transaction revert and error correction tools
612
+ - [ ] Rails::Engine for a pluggable API
613
+
614
+ ## Development
615
+
616
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
617
+
618
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
619
+
620
+ ## Contributing
621
+
622
+ Bug reports and pull requests are welcome on GitHub at https://github.com/mintyfresh/generalis.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'generalis'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/rspec ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require 'pathname'
12
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path('bundle', __dir__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require 'rubygems'
27
+ require 'bundler/setup'
28
+
29
+ load Gem.bin_path('rspec-core', 'rspec')