Repository: flash-oss/medici
Branch: master
Commit: bd97047ce4dc
Files: 63
Total size: 172.9 KB
Directory structure:
gitextract_7ssxit57/
├── .editorconfig
├── .eslintrc.json
├── .github/
│ └── workflows/
│ ├── check-code.yml
│ └── ci.yml
├── .gitignore
├── .nvmrc
├── .nycrc
├── LICENSE
├── README.md
├── bench/
│ ├── bench-balance.ts
│ └── bench-ledger.ts
├── package.json
├── spec/
│ ├── balance.spec.ts
│ ├── book.spec.ts
│ ├── constructKey.spec.ts
│ ├── extractObjectIdKeysFromSchema.spec.ts
│ ├── fpPrecision.spec.ts
│ ├── handleVoidMemo.spec.ts
│ ├── helper/
│ │ ├── MongoDB.spec.ts
│ │ ├── delay.ts
│ │ └── transactionSchema.ts
│ ├── index.spec.ts
│ ├── parseBalanceQuery.spec.ts
│ ├── parseDateField.spec.ts
│ ├── parseFilterQuery.spec.ts
│ ├── safeSetKeyToMetaObject.spec.ts
│ ├── setTransactionSchema.spec.ts
│ ├── types/
│ │ └── medici.spec-d.ts
│ └── xacid.spec.ts
├── src/
│ ├── Book.ts
│ ├── Entry.ts
│ ├── IAnyObject.ts
│ ├── IOptions.ts
│ ├── errors/
│ │ ├── BookConstructorError.ts
│ │ ├── ConsistencyError.ts
│ │ ├── InvalidAccountPathLengthError.ts
│ │ ├── JournalAlreadyVoidedError.ts
│ │ ├── JournalNotFoundError.ts
│ │ ├── MediciError.ts
│ │ ├── TransactionError.ts
│ │ └── index.ts
│ ├── helper/
│ │ ├── addReversedTransactions.ts
│ │ ├── extractObjectIdKeysFromSchema.ts
│ │ ├── flattenObject.ts
│ │ ├── handleVoidMemo.ts
│ │ ├── initModels.ts
│ │ ├── isPrototypeAttribute.ts
│ │ ├── mongoTransaction.ts
│ │ ├── parse/
│ │ │ ├── IFilter.ts
│ │ │ ├── parseAccountField.ts
│ │ │ ├── parseBalanceQuery.ts
│ │ │ ├── parseDateField.ts
│ │ │ └── parseFilterQuery.ts
│ │ ├── safeSetKeyToMetaObject.ts
│ │ └── syncIndexes.ts
│ ├── index.ts
│ └── models/
│ ├── balance.ts
│ ├── journal.ts
│ ├── lock.ts
│ └── transaction.ts
├── tsconfig.eslint.json
├── tsconfig.json
└── tsconfig.types.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[{*.js,*.ts}]
# Change these settings to your own preference
indent_style = space
indent_size = 2
max_line_length = 120
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .eslintrc.json
================================================
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.eslint.json"
},
"plugins": [
"import",
"promise",
"@typescript-eslint",
"security",
"security-node",
"sonarjs"
],
"rules": {
"no-empty": ["warn"],
"require-await": 2,
"no-return-await": 2,
"sonarjs/cognitive-complexity": ["warn", 20],
"@typescript-eslint/ban-ts-comment": 1,
"security/detect-object-injection": 0
},
"extends": [
"plugin:import/typescript",
"eslint:recommended",
"plugin:promise/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:security/recommended",
"plugin:security-node/recommended",
"plugin:sonarjs/recommended",
"plugin:import/errors",
"plugin:import/warnings"
]
}
================================================
FILE: .github/workflows/check-code.yml
================================================
name: "Check code"
on:
pull_request:
branches: [ master ]
jobs:
check-code:
name: Check Code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- name: Run prettier
run: npm run prettier
- name: Run lint
run: npm run lint
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on: [push, pull_request]
jobs:
test:
name: Node ${{ matrix.node }}, Mongo ${{ matrix.mongodb-version }}, on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [16, 18, 20]
mongodb-version: [4.0, 4.2, 4.4, 5.0, 6.0]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js v${{ matrix.node }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- name: Start MongoDB v${{ matrix.mongodb-version }}
uses: supercharge/mongodb-github-action@1.8.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: test-rs
# ignoring scripts to not install mongodb-memory-server
- run: npm ci --ignore-scripts
- run: npm run ci
- run: npm run ci-mongoose5
- run: npm run ci-mongoose6
- run: npm run ci-mongoose7
- run: npm run ci-mongoose8
================================================
FILE: .gitignore
================================================
/node_modules
/build
/types
/coverage
/.nyc_output
.idea
.npmrc
.DS_Store
================================================
FILE: .nvmrc
================================================
16
================================================
FILE: .nycrc
================================================
{
"extends": "@istanbuljs/nyc-config-typescript",
"all": true,
"exclude": [
"build",
"bench",
"coverage",
"dist",
"spec",
"src/index.ts"
],
"reporter": [
"text",
"lcov",
"text-summary"
],
"check-coverage": true,
"lines": 100,
"statements": 100,
"functions": 100,
"branches": 100
}
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2013 Jason Raede
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# medici
<div align="center">
[](https://github.com/flash-oss/medici/actions)
[](https://snyk.io/test/github/flash-oss/medici)
[](https://github.com/nodejs/security-wg/blob/HEAD/processes/responsible_disclosure_template.md)
[](https://www.npmjs.com/package/medici)
[](https://www.npmjs.com/package/medici)
</div>
Double-entry accounting system for nodejs + mongoose
```bash
npm i medici
```
## Basics
To use Medici you will need a working knowledge of JavaScript, Node.js, and Mongoose.
Medici divides itself into "books", each of which store _journal entries_ and their child _transactions_. The cardinal rule of double-entry accounting is that "for every debit entry, there must be a corresponding credit entry" which means "everything must balance out to zero", and that rule is applied to every journal entry written to the book. If the transactions for a journal entry do not balance out to zero, the system will throw a new error with the message `INVALID JOURNAL`.
Books simply represent the physical book in which you would record your transactions - on a technical level, the "book" attribute simply is added as a key-value pair to both the `Medici_Transactions` and `Medici_Journals` collection to allow you to have multiple books if you want to.
Each transaction in Medici is for one account. Additionally, sub accounts can be created, and are separated by a colon. Transactions to the Assets:Cash account will appear in a query for transactions in the Assets account, but will not appear in a query for transactions in the Assets:Property account. This allows you to query, for example, all expenses, or just "office overhead" expenses (Expenses:Office Overhead).
In theory, the account names are entirely arbitrary, but you will likely want to use traditional accounting sections and subsections like assets, expenses, income, accounts receivable, accounts payable, etc. But, in the end, how you structure the accounts is entirely up to you.
## Limitations:
- You can safely add values up to 9007199254740991 (Number.MAX_SAFE_INTEGER) and by default down to 0.00000001 (precision: 8).
- Anything more than 9007199254740991 or less than 0.00000001 (precision: 8) is not guaranteed to be handled properly.
You can set the floating point precision as follows:
```javascript
const myBook = new Book("MyBook", { precision: 7 });
```
## Writing journal entries
Writing a journal entry is very simple. First you need a `book` object:
```js
const { Book } = require("medici");
// The first argument is the book name, which is used to determine which book the transactions and journals are queried from.
const myBook = new Book("MyBook");
```
Now write an entry:
```js
// You can specify a Date object as the second argument in the book.entry() method if you want the transaction to be for a different date than today
const journal = await myBook
.entry("Received payment")
.debit("Assets:Cash", 1000)
.credit("Income", 1000, { client: "Joe Blow" })
.commit();
```
You can continue to chain debits and credits to the journal object until you are finished. The `entry.debit()` and `entry.credit()` methods both have the same arguments: (account, amount, meta).
You can use the "meta" field which you can use to store any additional information about the transaction that your application needs. In the example above, the `client` attribute is added to the transaction in the `Income` account, so you can later use it in a balance or transaction query to limit transactions to those for Joe Blow.
## Querying Account Balance
To query account balance, just use the `book.balance()` method:
```js
const { balance } = await myBook.balance({
account: "Assets:Accounts Receivable",
client: "Joe Blow",
});
console.log("Joe Blow owes me", balance);
```
Note that the `meta` query parameters are on the same level as the default query parameters (account, \_journal, start_date, end_date). Medici parses the query and automatically turns any values that do not match top-level schema properties into meta parameters.
## Retrieving Transactions
To retrieve transactions, use the `book.ledger()` method (here I'm using moment.js for dates):
```js
const startDate = moment().subtract("months", 1).toDate(); // One month ago
const endDate = new Date(); // today
const { results, total } = await myBook.ledger({
account: "Income",
start_date: startDate,
end_date: endDate,
});
```
## Customizing `readConcern` for Reads
Medici supports passing a custom MongoDB `readConcern` level when performing read operations such as `.balance()` or `.ledger()`. This is especially important when you're using MongoDB in a replica set configuration, where consistency and availability trade-offs must be considered.
#### Example:
```js
const { balance } = await myBook.balance(
{ account: "Assets:Cash" },
{ readConcern: "local" } // Options: "local", "majority", "available", etc.
);
```
```js
const { results, total } = await myBook.ledger(
{ account: "Income" },
{ readConcern: "local" }
);
```
#### ⚠️ Important Note on `readConcern: "majority"`
Using `readConcern: "majority"` in production has led to serious issues in MongoDB replica set environments, including:
- Negative balances appearing unexpectedly
- Recently credited transactions missing from balance queries
- Inconsistent ledger reads during failovers or secondary lag
These issues are caused by delays in replica set propagation, where majority-acknowledged reads may not reflect the latest writes. Since Medici relies on up-to-date read accuracy to maintain ledger integrity, stale reads can break fundamental accounting guarantees.
Switching to `readConcern: "local"` resolved all issues by ensuring reads are performed from the primary node, even if they are not yet acknowledged by the majority.
## Voiding Journal Entries
Sometimes you will make an entry that turns out to be inaccurate or that otherwise needs to be voided. Keeping with traditional double-entry accounting, instead of simply deleting that journal entry, Medici instead will mark the entry as "voided", and then add an equal, opposite journal entry to offset the transactions in the original. This gives you a clear picture of all actions taken with your book.
To void a journal entry, you can either call the `void(void_reason)` method on a Medici_Journal document, or use the `book.void(journal_id, void_reason)` method if you know the journal document's ID.
```js
await myBook.void("5eadfd84d7d587fb794eaacb", "I made a mistake");
```
If you do not specify a void reason, the system will set the memo of the new journal to the original journal's memo prepended with "[VOID]".
By default, voided journals will have the `datetime` set to the current date and time (at the time of voiding). Optionally, you can set `use_original_date` to `true` to use the original journal's `datetime` instead (`book.void(journal_id, void_reason, {}, true)`).
**Known Issue:** Note that, when using the original date, the cached balances will be out of sync until they are recalculated (within 48 hours at most). Check this [discussion](https://github.com/flash-oss/medici/issues/117) for more details.
## ACID checks of an account balance
Sometimes you need to guarantee that an account balance never goes negative. You can employ MongoDB ACID transactions for that. As of 2022 the recommended way is to use special Medici writelock mechanism. See comments in the code example below.
```typescript
import { Book, mongoTransaction } from "medici";
const mainLedger = new Book("mainLedger");
async function withdraw(walletId: string, amount: number) {
return mongoTransaction(async (session) => {
await mainLedger
.entry("Withdraw by User")
.credit("Assets", amount)
.debit(`Accounts:${walletId}`, amount)
.commit({ session });
// .balance() can be a resource-expensive operation. So we do it after we
// created the journal.
const balanceAfter = await mainLedger.balance(
{
account: `Accounts:${walletId}`,
},
{ session }
);
// Avoid spending more than the wallet has.
// Reject the ACID transaction by throwing this exception.
if (balanceAfter.balance < 0) {
throw new Error("Not enough balance in wallet.");
}
// ISBN: 978-1-4842-6879-7. MongoDB Performance Tuning (2021), p. 217
// Reduce the Chance of Transient Transaction Errors by moving the
// contentious statement to the end of the transaction.
// We writelock only the account of the User/Wallet. If we writelock a very
// often used account, like the fictitious Assets account in this example,
// we would slow down the database extremely as the writelocks would make
// it impossible to concurrently write in the database.
// We only check the balance of the User/Wallet, so only this Account has to
// be writelocked.
await mainLedger.writelockAccounts([`Accounts:${walletId}`], { session });
});
}
```
## Document Schema
Journals are schemed in Mongoose as follows:
```js
JournalSchema = {
datetime: Date,
memo: {
type: String,
default: "",
},
_transactions: [
{
type: Schema.Types.ObjectId,
ref: "Medici_Transaction",
},
],
book: String,
voided: {
type: Boolean,
default: false,
},
void_reason: String,
};
```
Transactions are schemed as follows:
```js
TransactionSchema = {
credit: Number,
debit: Number,
meta: Schema.Types.Mixed,
datetime: Date,
account_path: [String],
accounts: String,
book: String,
memo: String,
_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
timestamp: Date,
voided: {
type: Boolean,
default: false,
},
void_reason: String,
// The journal that this is voiding, if any
_original_journal: Schema.Types.ObjectId,
};
```
Note that the `book`, `datetime`, `memo`, `voided`, and `void_reason` attributes are duplicates of their counterparts on the Journal document. These attributes will pretty much be needed on every transaction search, so they are added to the Transaction document to avoid having to populate the associated Journal every time.
### Customizing the Transaction document schema
If you need to add additional fields to the schema that the `meta` won't satisfy, you can define your own schema for `Medici_Transaction` and utilise the `setJournalSchema` and `setTransactionSchema` to use those schemas. When you specify meta values when querying or writing transactions, the system will check the Transaction schema to see if those values correspond to actual top-level fields, and if so will set those instead of the corresponding `meta` field.
For example, if you want transactions to have a related "person" document, you can define the transaction schema like so and use setTransactionSchema to register it:
```js
MyTransactionSchema = {
_person: {
type: Schema.Types.ObjectId,
ref: "Person",
},
credit: Number,
debit: Number,
meta: Schema.Types.Mixed,
datetime: Date,
account_path: [String],
accounts: String,
book: String,
memo: String,
_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
timestamp: Date,
voided: {
type: Boolean,
default: false,
},
void_reason: String,
};
// add an index to the Schema
MyTransactionSchema.index({ void: 1, void_reason: 1 });
// assign the Schema to the Model
setTransactionSchema(MyTransactionSchema, undefined, { defaultIndexes: true });
// Enforce the index 'void_1_void_reason_1'
await syncIndexes({ background: false });
```
## Performance
### Fast balance
In medici v5 we introduced the so-called "fast balance" feature. [Here is the discussion](https://github.com/flash-oss/medici/issues/38). TL;DR: it caches `.balance()` call result once a day (customisable) to `medici_balances` collection.
If a database has millions of records then calculating the balance on half of them would take like 5 seconds. When this result is cached it takes few milliseconds to calculate the balance after that.
#### How it works under the hood
There are two hard problems in programming: cache invalidation and naming things. (C) Phil Karlton
Be default, when you call `book.blanace(...)` for the first time medici will cache its result to `medici_balances` (aka balance snapshot). By default, every doc there will be auto-removed as they have TTL of 48 hours. Meaning this cache will definitely expire in 2 days. Although, medici will try doing a second balance snapshot every 24 hours (default value). Thus, at any point of time there will be present from zero to two snapshots per balance query.
When you would call the `book.balance(...)` with the same exact arguments the medici will:
- retrieve the most recent snapshot if present,
- sum up only transactions inserted after the snapshot, and
- add the snapshot's balance to the sum.
In a rare case you wanted to remove some ledger entries from `medici_transactions` you would also need to remove all the `medici_balances` docs. Otherwise, the `.balance()` would be returning inaccurate data for up to 24 hours.
**IMPORTANT!**
To make this feature consistent we had to switch from client-generated IDs to MongoDB server generated IDs. See [forceServerObjectId](https://mongodb.github.io/node-mongodb-native/api-generated/mongoclient.html#constructor).
#### How to disable balance caching feature
When creating a book you need to pass the `balanceSnapshotSec: 0` option.
```js
const myBook = new Book("MyBook", { balanceSnapshotSec: 0 })
```
### Indexes
Medici adds a few **default** indexes on the `medici_transactions` collection:
```
"_journal": 1
```
```
"book": 1,
"accounts": 1,
"datetime": -1,
```
```
"book": 1,
"account_path.0": 1,
"account_path.1": 1,
"account_path.2": 1,
"datetime": -1,
```
However, if you are doing lots of queries using the `meta` data you probably would want to add the following index(es):
```
"book": 1,
"accounts": 1,
"meta.myClientId": 1,
"datetime": -1,
```
and/or
```
"book": 1,
"meta.myClientId": 1,
"account_path.0": 1,
"account_path.1": 1,
"account_path.2": 1,
"datetime": -1,
```
Here is how to add an index manually via MongoDB CLI or other tool:
```
db.getSiblingDB("my_db_name").getCollection("medici_transactions").createIndex({
"book": 1,
"accounts": 1,
"meta.myClientId": 1,
"datetime": -1,
}, { background: true })
```
For more information, see [Performance Best Practices: Indexing](https://www.mongodb.com/blog/post/performance-best-practices-indexing)
## Changelog
### 7.2
- support MongoDB's `readConcern` in `book.ledger()`, `book.void()`, `book.listAccounts()` and `.entry.commit()`. #120
### 7.1
- option to use date of original entry for the voiding instead of the current date. #118
### 7.0
Unluckily, all the **default** indexes were suboptimal. The `book` property always had the lowest cardinality. However, we always query by the `book` first and then by some other properties. Thus all the default indexes were near useless.
This release fixes the unfortunate mistake.
- The `book` property cardinality was moved to the beginning of all default indexes.
- Most of the default indexes were useless in majority of use cases. Thus, were removed. Only 4 default indexes left for `medici` v7.0:
- `_id`
- `_journal`
- `book,accounts,datetime`
- `book,account_path.0,account_path.1,account_path.2,datetime`
- The `datetime` is the only one to be used in the default indexes. Additional `timestamp` doesn't make any sense.
- Removed the `book.listAccounts()` caching which was added in the previous release (v6.3). The default indexes cover this use case now. Moreover, the index works faster than the cache.
### 6.3
- The `book.listAccounts()` method is now cached same way the `book.balance()` is cached.
- Add `mongoose` v8 support.
### 6.2
- Add `mongoose` v7 support.
- Add Node 20 support.
### 6.1
- Add MongoDB v6 support.
### 6.0
- Drop node 12 and 14 support. Only 16 and 18 are supported now.
- By default use the secondary nodes (if present) of your MongoDB cluster to calculate balances.
### v5.2
- The balances cache primary key is now a SHA1 hash of the previous value. Before: `"MyBook;Account;clientId.$in.0:12345,clientId.$in.1:67890,currency:USD"`. After: `"\u001b\u0004NÞj\u0013rÅ\u001b¼,F_#\u001cÔk Nv"`. Allows each key to be exactly 40 bytes (20 chars) regardless the actual balance query text length.
- But the old raw unhashed key is now stored in `rawKey` of `medici_balances` for DX and troubleshooting purposes.
- Fixed important bugs #58 and #70 related to retrieving balance for a custom schema properties. Thanks @dolcalmi
### v5.1
The balance snapshots were never recalculated from the beginning of the ledger. They were always based on the most recent snapshot. It gave us speed. Although, if one of the snapshots gets corrupt or an early ledger entry gets manually edited/deleted then we would always get wrong number from the `.balance()` method. Thus, we have to calculate snapshots from the beginning of the ledger at least once in a while.
BUT! If you have millions of documents in `medici_transactions` collection a full balance recalculation might take up to 10 seconds. So, we can't afford aggregation of the entire database during the `.blance()` invocation. Solution: let's aggregate it **in the background**. Thus, v5.1 was born.
New feature:
- In addition to the existing `balanceSnapshotSec` option, we added `expireBalanceSnapshotSec`.
- The `balanceSnapshotSec` tells medici how often you want those snapshots to be made **in the background** (right after the `.balance()` call). Default value - 24 hours.
- The `expireBalanceSnapshotSec` tells medici when to evict those snapshots from the database (TTL). It is recommended to set `expireBalanceSnapshotSec` higher than `balanceSnapshotSec`. Default value - twice the `balanceSnapshotSec`.
### v5.0
High level overview.
- The project was rewritten with **TypeScript**. Types are provided within the package now.
- Added support for MongoDB sessions (aka **ACID** transactions). See `IOptions` type.
- Did number of consistency, stability, server disk space, and speed improvements. Balance querying on massive databases with millions of documents are going to be much-much faster now. Like 10 to 1000 times faster.
- Mongoose v5 and v6 are both supported now.
Major breaking changes:
- The "approved" feature was removed.
- Mongoose middlewares on medici models are not supported anymore.
- The `.balance()` method does not support pagination anymore.
- Rename constructor `book` -> `Book`.
- Plus some other potentially breaking changes. See below.
Step by step migration from v4 to v5.
- Adapt your code to all the breaking changes.
- On the app start medici (actually mongoose) will create all the new indexes. If you have any custom indexes containing the `approved` property, you'd need to create similar indexes but without the property.
- You'd need to manually remove all the indexes which contain `approved` property in it.
- Done.
All changes of the release.
- Added a `mongoTransaction`-method, which is a convenience shortcut for `mongoose.connection.transaction`.
- Added async helper method `initModels`, which initializes the underlying `transactionModel` and `journalModel`. Use this after you connected to the MongoDB-Server if you want to use transactions. Or else you could get `Unable to read from a snapshot due to pending collection catalog changes; please retry the operation.` error when acquiring a session because the actual database-collection is still being created by the underlying mongoose-instance.
- Added `syncIndexes`. Warning! This function will erase any custom (non-builtin) indexes you might have added.
- Added `setJournalSchema` and `setTransactionSchema` to use custom Schemas. It will ensure, that all relevant middlewares and methods are also added when using custom Schemas. Use `syncIndexes`-method from medici after setTransactionSchema to enforce the defined indexes on the models.
- Added `maxAccountPath`. You can set the maximum amount of account paths via the second parameter of Book. This can improve the performance of `.balance()` and `.ledger()` calls as it will then use the accounts attribute of the transactions as a filter.
- MongoDB v4 and above is supported. You can still try using MongoDB v3, but it's not recommended.
- Added a new `timestamp+datetime` index on the transactionModel to improve the performance of paginated ledger queries.
- Added a `lockModel` to make it possible to call `.balance()` and **get a reliable result while using a mongo-session**. Call `.writelockAccounts()` with first parameter being an Array of Accounts, which you want to lock. E.g. `book.writelockAccounts(["Assets:User:User1"], { session })`. For best performance call writelockAccounts as the last operation in the transaction. Also `.commit()` accepts the option `writelockAccounts`, where you can provide an array of accounts or a RegExp. It is recommended to use the `book.writelockAccounts()`.
- **POTENTIALLY BREAKING**: Node.js 12 is the lowest supported version. Although, 10 should still work fine.
- **POTENTIALLY BREAKING**: MongoDB v4.0 is the lowest supported version. The v3.6 support was dropped.
- **POTENTIALLY BREAKING**: `.ledger()` returns lean Transaction-Objects (POJO) for better performance. To retrieve hydrated mongoose models set `lean` to `false` in the third parameter of `.ledger()`. It is recommended to not hydrate the transactions, as it implies that the transactions could be manipulated and the data integrity of Medici could be risked.
- **POTENTIALLY BREAKING**: Rounding precision was changed from 7 to 8 floating point digits.
- The new default precision is 8 digits. The medici v4 had it 7 by default. Be careful if you are using values which have more than 8 digits after the comma.
- You can now specify the `precision` in the `Book` constructor as an optional second parameter `precision`. Simulating medici v4 behaviour: `new Book("MyBook", { precision: 7 })`.
- Also, you can enforce an "only-Integer" mode, by setting the precision to 0. But keep in mind that Javascript has a max safe integer limit of 9007199254740991.
- **POTENTIALLY BREAKING**: Added validation for `name` of Book, `maxAccountPath` and `precision`.
- The `name` has to be not an empty string or a string containing only whitespace characters.
- `precision` has to be an integer bigger or equal 0.
- `maxAccountPath` has to be an integer bigger or equal 0.
- **POTENTIALLY BREAKING**: Added prototype-pollution protection when creating entries. Reserved words like `__proto__` can not be used as properties of a Transaction or a Journal or their meta-Field. They will get silently filtered out.
- **POTENTIALLY BREAKING**: When calling `book.void()` the provided `journal_id` has to belong to the `book`. If the journal does not exist within the book, medici will throw a `JournalNotFoundError`. In medici < 5 you could theoretically void a `journal` of another `book`.
- **POTENTIALLY BREAKING**: Transaction document properties `meta`, `voided`, `void_reason`, `_original_journal` won't be stored to the database when have no data. In medici v4 they were `{}`, `false`, `null`, `null` correspondingly.
- **BREAKING**: If you had any Mongoose middlewares (e.g. `"pre save"`) installed onto medici `transactionModel` or `journalModel` then they won't work anymore. Medici v5 is not using the mongoose to do DB operations. Instead, we execute commands via bare `mongodb` driver.
- **BREAKING**: `.balance()` does not support pagination anymore. To get the balance of a page sum up the values of credit and debit of a paginated `.ledger()`-call.
- **BREAKING**: You can't import `book` anymore. Only `Book` is supported. `require("medici").Book`.
- **BREAKING**: The approving functionality (`approved` and `setApproved()`) was removed. It's complicating code, bloating the DB, not used by anyone maintainers know. Please, implement approvals outside the ledger. If you still need it to be part of the ledger then you're out of luck and would have to (re)implement it yourself. Sorry about that.
### v4.0
- Node.js 8 is required now.
- Drop support of Mongoose v4. Only v5 is supported now. (But v4 should just work, even though not tested.)
- No API changes.
### v3.0
- Add 4 mandatory indexes, otherwise queries get very slow when transactions collection grows.
- No API changes.
### v2.0
- Upgrade to use mongoose v5. To use with mongoose v4 just `npm i medici@1`.
- Support node.js v10.
- No API changes.
### v1.0
_See [this PR](https://github.com/flash-oss/medici/pull/5) for more details_
- **BREAKING**: Dropped support of node.js v0.10, v0.12, v4, and io.js. Node.js >= v6 is supported only. This allowed to drop several production dependencies. Also, few bugs were automatically fixed.
- **BREAKING**: Upgraded `mongoose` to v4. This allows `medici` to be used with wider mongodb versions.
- Dropped production dependencies: `moment`, `q`, `underscore`.
- Dropped dev dependencies: `grunt`, `grunt-exec`, `grunt-contrib-coffee`, `grunt-sed`, `grunt-contrib-watch`, `semver`.
- No `.coffee` any more. Using node.js v6 compatible JavaScript only.
- There are no API changes.
- Fixed a [bug](https://github.com/flash-oss/medici/issues/4). Transaction meta data was not voided correctly.
- This module maintainer is now [flash-oss](https://github.com/flash-oss) instead of the original author [jraede](http://github.com/jraede).
================================================
FILE: bench/bench-balance.ts
================================================
import * as mongoose from "mongoose";
import { MongoMemoryReplSet } from "mongodb-memory-server";
import { Book, initModels } from "../src";
let replSet: MongoMemoryReplSet;
(async () => {
replSet = new MongoMemoryReplSet({
binary: {
version: "4.2.5",
},
instanceOpts: [
// Set the expire job in MongoDB to run every second
{ args: ["--setParameter", "ttlMonitorSleepSecs=1"] },
],
replSet: {
name: "rs0",
storageEngine: "wiredTiger",
},
});
replSet.start();
await replSet.waitUntilRunning();
const connectionString = replSet.getUri();
await mongoose.connect(connectionString, {
bufferCommands: false,
noDelay: true,
});
await initModels();
const book = new Book("MyBook");
for (let i = 0; i < 5000; i++) {
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
await book
.entry(`Test Entry ${i}`, threeDaysAgo)
.debit("Assets:Receivable", 700)
.credit("Income:Rent", 700)
.commit();
i % 100 === 0 && console.log(i);
}
console.log("start benchmark");
const start = Date.now();
for (let i = 0; i < 1000; i++) {
await book.balance({
account: "Income:Rent",
});
i % 100 === 0 && console.log(i);
}
console.log((Date.now() - start) / 1000);
process.exit(0);
})();
================================================
FILE: bench/bench-ledger.ts
================================================
import * as mongoose from "mongoose";
import { MongoMemoryReplSet } from "mongodb-memory-server";
import { Book, initModels } from "../src";
let replSet: MongoMemoryReplSet;
(async () => {
replSet = new MongoMemoryReplSet({
binary: {
version: "4.2.5",
},
instanceOpts: [
// Set the expire job in MongoDB to run every second
{ args: ["--setParameter", "ttlMonitorSleepSecs=1"] },
],
replSet: {
name: "rs0",
storageEngine: "wiredTiger",
},
});
replSet.start();
await replSet.waitUntilRunning();
const connectionString = replSet.getUri();
await mongoose.connect(connectionString, {
bufferCommands: false,
noDelay: true,
});
await initModels();
const book = new Book("MyBook");
for (let i = 0; i < 5000; i++) {
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
await book
.entry(`Test Entry ${i}`, threeDaysAgo)
.debit("Assets:Receivable", 700)
.credit("Income:Rent", 700)
.commit();
i % 100 === 0 && console.log(i);
}
console.log("start benchmark");
const start = Date.now();
for (let i = 0; i < 1000; i++) {
await book.ledger({
account: "Income:Rent",
perPage: 34,
page: 5,
});
i % 100 === 0 && console.log(i);
}
console.log((Date.now() - start) / 1000);
process.exit(0);
})();
================================================
FILE: package.json
================================================
{
"name": "medici",
"version": "7.2.0",
"description": "Double-entry accounting ledger for Node + Mongoose",
"main": "build/index.js",
"types": "types/index.d.ts",
"scripts": {
"ci": "npm run build && npm run test",
"ci-mongoose8": "npm i mongoose@8 && npm run test:code",
"ci-mongoose7": "npm i mongoose@7 && npm run test:code",
"ci-mongoose6": "npm i mongoose@6 && npm run test:code",
"ci-mongoose5": "npm i mongoose@5 && npm run test:code",
"bench:balance": "ts-node ./bench/bench-balance.ts",
"bench:ledger": "ts-node ./bench/bench-ledger.ts",
"build": "npm run clean && npm run build:node && npm run build:types",
"build:node": "tsc -p .",
"build:types": "dts-bundle-generator -o ./types/index.d.ts ./src/index.ts --project ./tsconfig.types.json --no-check",
"clean": "rimraf ./build && rimraf ./types",
"prettier": "prettier '**/*.ts' --check",
"prettier:fix": "prettier '**/*.ts' --write",
"lint": "eslint ./spec ./src",
"lint:fix": "eslint --fix ./spec ./src",
"test": "npm run test:code && npm run test:types",
"test:code": "ts-mocha --recursive './spec/*.ts' --preserve-symlinks",
"test:types": "tsd",
"test:coverage": "npm run clean && USE_MEMORY_REPL_SET=true nyc --reporter html ts-mocha --recursive './spec/**/*.spec.ts'",
"prepublishOnly": "npm run build"
},
"files": [
"build/",
"types/"
],
"repository": {
"type": "git",
"url": "https://github.com/flash-oss/medici"
},
"keywords": [
"double-entry",
"accounting",
"account",
"finance",
"mongodb",
"mongoose",
"ledger"
],
"author": {
"name": "Jason Raede"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/flash-oss/medici/issues"
},
"dependencies": {
"mongoose": "5 - 8"
},
"homepage": "https://github.com/flash-oss/medici",
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@types/chai": "^4.3.5",
"@types/luxon": "^3.3.5",
"@types/mocha": "^10.0.1",
"@types/node": "^18.16.17",
"@types/sinon": "^10.0.15",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"chai": "^4.3.4",
"dts-bundle-generator": "^8.0.1",
"eslint": "^8.42.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-security": "^1.4.0",
"eslint-plugin-security-node": "^1.0.14",
"eslint-plugin-sonarjs": "^0.19.0",
"luxon": "^3.0.1",
"mocha": "^10.2.0",
"moment": "^2.29.1",
"mongodb-memory-server": "^8.13.0",
"nyc": "^15.1.0",
"prettier": "^2.8.8",
"rimraf": "^5.0.1",
"sinon": "^15.1.0",
"ts-mocha": "^10.0.0",
"tsd": "^0.28.1",
"typescript": "^5.1.3"
},
"tsd": {
"directory": "spec/types",
"compilerOptions": {
"esModuleInterop": false,
"module": "commonjs",
"target": "ES2017"
}
},
"config": {
"mongodbMemoryServer": {
"version": "4.2.5"
}
},
"engines": {
"node": ">=16"
}
}
================================================
FILE: spec/balance.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off, no-prototype-builtins: off*/
import { expect } from "chai";
import { Book, syncIndexes } from "../src";
import { balanceModel, getBestBalanceSnapshot } from "../src/models/balance";
import { setTransactionSchema, transactionModel, transactionSchema } from "../src/models/transaction";
import { getTransactionSchemaTest, ITransactionTest } from "./helper/transactionSchema";
describe("balance model", function () {
describe("getBestBalanceSnapshot", () => {
it("should find snapshot", async function () {
const book = new Book("MyBook-balance-1");
await book.entry("Test 1").credit("Assets:Receivable", 1).debit("Income:Rent", 1).commit();
const balance1 = await book.balance({ account: "Assets:Receivable" });
expect(balance1).to.deep.equal({ balance: 1, notes: 1 });
const snapshot = await getBestBalanceSnapshot({ book: book.name, account: "Assets:Receivable" });
expect(snapshot).to.have.property("balance", 1);
});
it("should return proper number of notes", async function () {
const book = new Book("MyBook-balance-notes");
await book.entry("Test 1").credit("Assets:Receivable", 1).debit("Income:Rent", 1).commit();
const balance1 = await book.balance({ account: "Assets:Receivable" });
expect(balance1).to.deep.equal({ balance: 1, notes: 1 });
const balance2 = await book.balance({ account: "Assets:Receivable" });
expect(balance2).to.deep.equal({ balance: 1, notes: 1 });
await book.entry("Test 1").credit("Assets:Receivable", 1).debit("Income:Rent", 1).commit();
const balance3 = await book.balance({ account: "Assets:Receivable" });
expect(balance3).to.deep.equal({ balance: 2, notes: 2 });
});
it("should not confuse snapshots", async function () {
const book = new Book("MyBook-balance-2");
const meta = { clientId: "12345", otherMeta: 1 };
await book.entry("T2E1").credit("Assets:Receivable", 1, meta).debit("Income:Rent", 1, meta).commit();
await book.entry("T2E2").credit("Assets:Receivable", 2).debit("Income:Rent", 2).commit();
let balance, snapshot;
// should create a balance with meta
balance = await book.balance({ account: "Assets:Receivable", clientId: "12345" });
expect(balance).to.deep.equal({ balance: 1, notes: 1 });
// balance with meta should equal 1.0
snapshot = await getBestBalanceSnapshot({
book: book.name,
account: "Assets:Receivable",
meta: { clientId: "12345" },
});
expect(snapshot).to.have.property("balance", 1);
// there must be no balance without meta (yet)
snapshot = await getBestBalanceSnapshot({ book: book.name, account: "Assets:Receivable" });
expect(snapshot).to.be.not.ok;
// this should create a new balance
balance = await book.balance({ account: "Assets:Receivable" });
expect(balance).to.deep.equal({ balance: 3, notes: 2 });
// check if previously missing balance was created and equals to 3.0
snapshot = await getBestBalanceSnapshot({ book: book.name, account: "Assets:Receivable" });
expect(snapshot).to.have.property("balance", 3);
});
it("should not confuse snapshots - reverse querying", async function () {
const book = new Book("MyBook-balance-3");
const meta = { clientId: "12345", otherMeta: 1 };
await book.entry("T2E1").credit("Assets:Receivable", 1, meta).debit("Income:Rent", 1, meta).commit();
await book.entry("T2E2").credit("Assets:Receivable", 2).debit("Income:Rent", 2).commit();
let balance, snapshot;
// should create a balance without meta
balance = await book.balance({ account: "Assets:Receivable" });
expect(balance).to.deep.equal({ balance: 3, notes: 2 });
// balance without meta should equal 3.0
snapshot = await getBestBalanceSnapshot({ book: book.name, account: "Assets:Receivable" });
expect(snapshot).to.have.property("balance", 3);
// there must be no balance with meta (yet)
snapshot = await getBestBalanceSnapshot({
book: book.name,
account: "Assets:Receivable",
meta: { clientId: "12345" },
});
expect(snapshot).to.be.not.ok;
// this should create a new balance, this time with meta
balance = await book.balance({ account: "Assets:Receivable", clientId: "12345" });
expect(balance).to.deep.equal({ balance: 1, notes: 1 });
// check if previously missing balance was created and equals to 1.0
snapshot = await getBestBalanceSnapshot({
book: book.name,
account: "Assets:Receivable",
meta: { clientId: "12345" },
});
expect(snapshot).to.have.property("balance", 1);
});
it("should snapshot with mongodb query language", async function () {
const book = new Book("MyBook-balance-mongodb-query-language");
await book
.entry("Test 1")
.credit("Assets:Receivable", 1, { clientId: "12345" })
.debit("Income:Rent", 1, { clientId: "12345" })
.commit();
const balance1 = await book.balance({ account: "Assets:Receivable", clientId: { $in: ["12345", "67890"] } });
expect(balance1).to.deep.equal({ balance: 1, notes: 1 });
const snapshot = await getBestBalanceSnapshot({
book: book.name,
account: "Assets:Receivable",
meta: { clientId: { $in: ["12345", "67890"] } },
});
expect(snapshot).to.exist;
expect(snapshot).to.have.property("balance", 1);
// Let's make sure the snapshot is used when mongodb query language is present in the query
await balanceModel.collection.updateOne({ key: snapshot?.key }, { $set: { balance: 300 } });
const balance2 = await book.balance({ account: "Assets:Receivable", clientId: { $in: ["12345", "67890"] } });
expect(balance2).to.deep.equal({ balance: 300, notes: 1 });
});
it("should ignore the order of doc insertion", async function () {
const book = new Book("MyBook-id-order");
const journal = await book.entry("Test 1").debit("Income:Rent", 1).credit("Assets:Receivable", 1).commit();
const t1 = await transactionModel.findOne({ _journal: journal }).sort("-_id").exec(); // last transaction
expect(t1).to.exist;
await book.entry("Test 2").debit("Income:Rent", 1).credit("Assets:Receivable", 1).commit();
const balance1 = await book.balance({ account: "Assets:Receivable" });
expect(balance1).to.deep.equal({ balance: 2, notes: 2 });
// To simulate medici v4 behaviour we need to clean up the `balances` collection.
await balanceModel.collection.deleteMany({ book: "MyBook-id-order" });
// We need to change the order of transactions in the database.
// The first inserted doc must have the largest _id for this unit test.
// Copying it the first transaction to the end and remove it.
const t1Object = t1?.toObject();
await t1?.deleteOne(); // we have to remove BEFORE creating the clone because otherwise MongoDB sees the stale (removed) doc!!!
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete t1Object._id;
await transactionModel.create(t1Object);
const balance2 = await book.balance({ account: "Assets:Receivable" });
expect(balance2).to.deep.equal({ balance: 2, notes: 2 });
});
});
describe("getBestBalanceSnapshot with custom schema", () => {
let book: Book<ITransactionTest>;
before(async function () {
const transactionSchemaTest = getTransactionSchemaTest();
setTransactionSchema(transactionSchemaTest, undefined, {
defaultIndexes: false,
});
book = new Book<ITransactionTest>("MyBook-balance-custom-attrs-1");
const meta = { clientId: "12345" };
await book
.entry("Test 1")
.credit("Assets:Receivable", 1)
.debit("Income:Rent", 1)
.credit("Assets:Receivable", 1, meta)
.debit("Income:Rent", 1, meta)
.credit("Assets:Receivable", 1, { ...meta, otherMeta: 1 })
.debit("Income:Rent", 1, { ...meta, otherMeta: 1 })
.commit();
});
after(async function () {
setTransactionSchema(transactionSchema);
await syncIndexes({ background: false });
});
it("should find snapshot by account", async function () {
const account = "Assets:Receivable";
const balance = await book.balance({ account });
expect(balance).to.deep.equal({ balance: 3, notes: 3 });
const res = await book.ledger({ account });
expect(res.results).to.have.lengthOf(3);
expect(res.results[2]).to.have.property("clientId");
expect(res.results[2]).to.not.have.property("otherMeta");
expect(res.results[2].meta).to.have.property("otherMeta");
expect(res.results[2].meta).to.not.have.property("clientId");
const snapshot = await getBestBalanceSnapshot({ book: book.name, account });
expect(snapshot).to.have.property("balance", 3);
});
it("should find snapshot with custom attribute", async function () {
const account = "Assets:Receivable";
const clientId = "12345";
const balance = await book.balance({ account, clientId });
expect(balance).to.deep.equal({ balance: 2, notes: 2 });
const res = await book.ledger({ account, clientId });
expect(res.results).to.have.lengthOf(2);
expect(res.results[1]).to.have.property("clientId");
expect(res.results[1]).to.not.have.property("otherMeta");
expect(res.results[1].meta).to.have.property("otherMeta");
expect(res.results[1].meta).to.not.have.property("clientId");
const snapshot = await getBestBalanceSnapshot({ book: book.name, account, clientId });
expect(snapshot).to.have.property("balance", 2);
});
it("should find snapshot with custom attribute and meta", async function () {
const account = "Assets:Receivable";
const clientId = "12345";
const otherMeta = 1;
const balance = await book.balance({ account, clientId, otherMeta });
expect(balance).to.deep.equal({ balance: 1, notes: 1 });
const res = await book.ledger({ account, clientId, otherMeta });
expect(res.results).to.have.lengthOf(1);
expect(res.results[0]).to.have.property("clientId");
expect(res.results[0]).to.not.have.property("otherMeta");
expect(res.results[0].meta).to.have.property("otherMeta");
expect(res.results[0].meta).to.not.have.property("clientId");
const snapshot1 = await getBestBalanceSnapshot({ book: book.name, account, clientId, meta: { otherMeta } });
expect(snapshot1).to.have.property("balance", 1);
const snapshot2 = await getBestBalanceSnapshot({ book: book.name, account, clientId, otherMeta });
expect(snapshot2).to.have.property("balance", 1);
});
});
});
================================================
FILE: spec/book.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off, @typescript-eslint/no-non-null-assertion: off, no-prototype-builtins: off*/
import { Book, JournalNotFoundError } from "../src";
import { Document, Types } from "mongoose";
import { IJournal } from "../src/models/journal";
import { expect } from "chai";
import { spy } from "sinon";
import { transactionModel } from "../src/models/transaction";
import { balanceModel } from "../src/models/balance";
import delay from "./helper/delay";
import * as moment from "moment";
import { DateTime } from "luxon";
require("./helper/MongoDB.spec"); // allows single test debugging
describe("book", function () {
describe("constructor", () => {
it("should throw an error when name of book is not a string", () => {
// @ts-expect-error we need a string
expect(() => new Book(1337)).to.throw("Invalid value for name provided.");
});
it("should throw an error when name of book is empty string", () => {
expect(() => new Book("")).to.throw("Invalid value for name provided.");
});
it("should throw an error when name of book is a string with only whitespace", () => {
expect(() => new Book(" ")).to.throw("Invalid value for name provided.");
});
it("should throw an error when maxAccountPath of book is a fraction", () => {
expect(() => new Book("MyBook", { maxAccountPath: 3.14 })).to.throw("Invalid value for maxAccountPath provided.");
});
it("should throw an error when maxAccountPath of book is a negative number", () => {
expect(() => new Book("MyBook", { maxAccountPath: -3 })).to.throw("Invalid value for maxAccountPath provided.");
});
it("should throw an error when maxAccountPath of book is not a number", () => {
// @ts-expect-error we need a number
expect(() => new Book("MyBook", { maxAccountPath: "7" })).to.throw("Invalid value for maxAccountPath provided.");
});
it("should throw an error when precision of book is a fraction", () => {
expect(() => new Book("MyBook", { precision: 3.14 })).to.throw("Invalid value for precision provided.");
});
it("should throw an error when precision of book is a negative number", () => {
expect(() => new Book("MyBook", { precision: -3 })).to.throw("Invalid value for precision provided.");
});
it("should throw an error when precision of book is not a number", () => {
// @ts-expect-error we need a number
expect(() => new Book("MyBook", { precision: "7" })).to.throw("Invalid value for precision provided.");
});
it("should throw an error when balanceSnapshotSec of book is not a number", () => {
// @ts-expect-error we need a number
expect(() => new Book("MyBook", { balanceSnapshotSec: "999" })).to.throw(
"Invalid value for balanceSnapshotSec provided."
);
});
it("should throw an error when balanceSnapshotSec of book is a negative number", () => {
expect(() => new Book("MyBook", { balanceSnapshotSec: -3 })).to.throw(
"Invalid value for balanceSnapshotSec provided."
);
});
it("should throw an error when expireBalanceSnapshotSec of book is not a number", () => {
// @ts-expect-error we need a number
expect(() => new Book("MyBook", { expireBalanceSnapshotSec: "999" })).to.throw(
"Invalid value for expireBalanceSnapshotSec provided."
);
});
it("should throw an error when expireBalanceSnapshotSec of book is a negative number", () => {
expect(() => new Book("MyBook", { expireBalanceSnapshotSec: -3 })).to.throw(
"Invalid value for expireBalanceSnapshotSec provided."
);
});
});
describe("journaling", () => {
it("should error when trying to use an account with more than three parts", () => {
expect(() => {
const book = new Book("MyBookAccounts");
book.entry("depth test").credit("X:Y:AUD:BTC", 1);
}).to.throw("Account path is too deep (maximum 3)");
});
it("should allow more than 4 subaccounts of third level", async function () {
const book = new Book("MyBookSubaccounts");
await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
.credit("X:Y:CHF", 1)
.debit("CashAssets", 5)
.commit();
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(5);
const accounts = await book.listAccounts();
expect(accounts).to.have.lengthOf(8);
expect(accounts).to.include("X");
expect(accounts).to.include("X:Y");
expect(accounts).to.include("X:Y:AUD");
expect(accounts).to.include("X:Y:EUR");
expect(accounts).to.include("X:Y:USD");
expect(accounts).to.include("X:Y:INR");
expect(accounts).to.include("X:Y:CHF");
expect(accounts).to.include("CashAssets");
});
it("should let you create and query a basic transaction", async function () {
const book = new Book("MyBook-basic-transaction");
const journal = await book
.entry("Test Entry")
.debit("Assets:Receivable", 500, { clientId: "12345", bookmarked: true })
.credit("Income:Rent", 500)
.commit();
expect(journal.memo).to.be.equal("Test Entry");
expect(journal._transactions).to.be.have.lengthOf(2);
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const journal1 = await book
.entry("Test Entry 2", threeDaysAgo)
.debit("Assets:Receivable", 700)
.credit("Income:Rent", 700, { clientId: "12345", bookmarked: true })
.commit();
expect(journal1.book).to.be.equal("MyBook-basic-transaction");
expect(journal1.memo).to.be.equal("Test Entry 2");
expect(journal._transactions).to.be.have.lengthOf(2);
const entries0 = await book.ledger({ clientId: "12345" });
expect(entries0.total).to.equal(2);
expect(entries0.results[0]).to.have.property("debit", 500);
expect(entries0.results[1]).to.have.property("credit", 700);
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const entries1 = await book.ledger({ accounts: "Assets:Receivable", start_date: moment(twoDaysAgo) });
expect(entries1.total).to.equal(1);
expect(entries1.results[0]).to.have.property("debit", 500);
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const entries2 = await book.ledger({ accounts: "Assets:Receivable", start_date: DateTime.fromJSDate(oneDayAgo) });
expect(entries2.total).to.equal(1);
expect(entries1.results[0]).to.have.property("debit", 500);
});
it("should let you use strings for amounts", async function () {
const book = new Book("MyBookAmountStrings");
await book
.entry("Test Entry")
.debit("Assets:Receivable", "500", { clientId: "12345" })
.credit("Income:Rent", "500")
.commit();
let result = await book.balance({ account: "Assets" });
expect(result.balance).to.be.equal(-500);
result = await book.balance({ account: "Income" });
expect(result.balance).to.be.equal(500);
});
it("should allow meta querying using mongodb query language", async function () {
const book = new Book("MyBookAmountStrings-mongodb-query-language");
await book
.entry("Test Entry")
.debit("Assets:Receivable", 123, { clientId: "12345" })
.credit("Income:Rent", 123)
.commit();
const result = await book.balance({ account: "Assets:Receivable", clientId: { $in: ["12345", "67890"] } });
expect(result.balance).to.be.equal(-123);
});
it("should let you use string for original journal", async function () {
const book = new Book("MyBookAmountStrings");
const journal = await book
.entry("Test Entry", null, "012345678901234567890123")
.debit("Assets:Receivable", "500", { clientId: "12345" })
.credit("Income:Rent", "500")
.commit();
expect(journal._original_journal).to.be.instanceOf(Types.ObjectId);
expect(journal._original_journal!.toString()).to.be.equal("012345678901234567890123");
});
it("should throw INVALID_JOURNAL if an entry total is !=0 and <0", async () => {
const book = new Book("MyBook-invalid");
const entry = book.entry("This is a test entry");
entry.debit("Assets:Cash", 99.9, {});
entry.credit("Income", 99.8, {});
try {
await entry.commit();
expect.fail("Should have thrown");
} catch (e) {
expect((e as Error).message).to.be.equal("INVALID_JOURNAL: can't commit non zero total");
}
});
it("should throw INVALID_JOURNAL if an entry total is !=0 and >0", async () => {
const book = new Book("MyBook");
const entry = book.entry("This is a test entry");
entry.debit("Assets:Cash", 99.8, {});
entry.credit("Income", 99.9, {});
try {
await entry.commit();
expect.fail("Should have thrown");
} catch (e) {
expect((e as Error).message).to.be.equal("INVALID_JOURNAL: can't commit non zero total");
}
});
it("should handle extra data when creating an Entry", async () => {
const book = new Book("MyBook-Entry-Test" + new Types.ObjectId().toString());
await book
.entry("extra")
.credit("A:B", 1, { credit: 2, clientId: "Mr. A" })
.debit("A:B", 1, { debit: 2, clientId: "Mr. B" })
.commit();
const { balance } = await book.balance({ account: "A:B" });
expect(balance).to.be.equal(0);
const res = await book.ledger({ account: "A:B" });
if (res.results[0].meta!.clientId === "Mr. A") {
expect(res.results[0].credit).to.be.equal(2);
expect(res.results[0].meta!.clientId).to.be.equal("Mr. A");
expect(res.results[1].debit).to.be.equal(2);
expect(res.results[1].meta!.clientId).to.be.equal("Mr. B");
} else {
expect(res.results[1].credit).to.be.equal(2);
expect(res.results[1].meta!.clientId).to.be.equal("Mr. A");
expect(res.results[0].debit).to.be.equal(2);
expect(res.results[0].meta!.clientId).to.be.equal("Mr. B");
}
});
it("should save all transactions in bulk and mitigate mongodb 'insertedIds' bug", async () => {
const book = new Book("MyBook-Entry-Test-bulk-saving");
const saveSpy = spy(transactionModel.collection, "insertMany");
const findSpy = spy(transactionModel.collection, "find");
try {
await book
.entry("extra")
.debit("A:B", 1, { debit: 2, clientId: "Mr. B" })
.credit("A:B", 1, { credit: 2 })
.commit();
} finally {
saveSpy.restore();
findSpy.restore();
}
expect(saveSpy.callCount).equal(1); // should attempts saving both transactions in parallel
expect(saveSpy.firstCall.args[1]).include({
forceServerObjectId: true,
ordered: true,
});
expect(findSpy.firstCall.args[0]).have.property("_journal");
expect(findSpy.firstCall.args[1]!.projection).have.property("_id", 1);
const { balance } = await book.balance({ account: "A:B" });
expect(balance).to.be.equal(0);
});
});
describe("balance", () => {
async function addBalance(book: Book) {
await book
.entry("Test Entry")
.debit("Assets:Receivable", 700, { clientId: "67890", otherProp: 1 })
.credit("Income:Rent", 700)
.commit();
await book
.entry("Test Entry")
.debit("Assets:Receivable", 500, { clientId: "12345", otherProp: 1 })
.credit("Income:Rent", 500)
.commit();
}
it("should give you the balance", async () => {
const book = new Book("MyBook-balance");
await addBalance(book);
const data = await book.balance({ account: "Assets" });
expect(data.balance).to.be.equal(-1200);
});
it("should give you the balance with partial meta queries", async () => {
const book = new Book("MyBook-balance with meta");
await addBalance(book);
const data1 = await book.balance({ account: "Assets:Receivable", clientId: "67890" });
expect(data1.balance).to.be.equal(-700);
const data2 = await book.balance({ account: "Assets:Receivable", clientId: "12345" });
expect(data2.balance).to.be.equal(-500);
});
it("should give you the balance without providing the account", async () => {
const book = new Book("MyBook-balance-no-account");
await addBalance(book);
const data = await book.balance({});
expect(data.balance).to.be.equal(0);
const snapshots = await balanceModel.find({ book: book.name });
expect(snapshots).to.have.length(1);
expect(snapshots[0].balance).to.equal(0);
});
it("should give you the total balance for multiple accounts", async () => {
const book = new Book("MyBook-balance-multiple");
await addBalance(book);
const data = await book.balance({ account: ["Assets", "Income"] });
expect(data.balance).to.be.equal(0);
});
it("should reuse the snapshot balance", async () => {
const book = new Book("MyBook-balance-snapshot");
await addBalance(book);
await book.balance({ account: "Assets" });
const snapshots = await balanceModel.find({ account: "Assets", book: book.name });
expect(snapshots).to.have.length(1);
expect(snapshots[0].balance).to.equal(-1200);
snapshots[0].balance = 999;
await snapshots[0].save();
const data = await book.balance({ account: "Assets" });
expect(data.balance).to.equal(999);
});
it("should reuse the snapshot balance when meta is in the query", async () => {
const book = new Book("MyBook-balance-snapshot-with-meta");
await addBalance(book);
await book.balance({ account: "Assets", clientId: "12345" });
const snapshots = await balanceModel.find({
account: "Assets",
book: book.name,
meta: JSON.stringify({ clientId: "12345" }),
});
expect(snapshots).to.have.length(1);
expect(snapshots[0].balance).to.equal(-500);
expect(snapshots[0].meta).to.equal('{"clientId":"12345"}');
snapshots[0].balance = 999;
await snapshots[0].save();
const data = await book.balance({ account: "Assets", clientId: "12345" });
expect(data.balance).to.equal(999);
});
it("should create only one snapshot document", async () => {
const book = new Book("MyBook-balance-snapshot-count");
await addBalance(book);
await book.balance({ account: "Assets", clientId: "12345" });
await book.balance({ account: "Assets", clientId: "12345" });
await book.balance({ account: "Assets", clientId: "12345" });
const snapshots = await balanceModel.find({
account: "Assets",
book: book.name,
meta: JSON.stringify({ clientId: "12345" }),
});
expect(snapshots).to.have.length(1);
expect(snapshots[0].balance).to.equal(-500);
expect(snapshots[0].meta).to.equal('{"clientId":"12345"}');
});
it("should create periodic balance snapshot document", async () => {
const howOften = 50; // milliseconds
const book = new Book("MyBook-balance-snapshot-periodic", { balanceSnapshotSec: howOften / 1000 });
await addBalance(book);
await book.balance({ account: "Assets" });
// Should be one snapshot.
let snapshots = await balanceModel.find({ account: "Assets", book: book.name });
expect(snapshots.length).to.equal(1);
expect(snapshots[0].balance).to.equal(-1200);
await delay(howOften + 1); // wait long enough to create a second periodic snapshot
await addBalance(book);
await book.balance({ account: "Assets" });
await delay(10); // wait until the full balance snapshot is recalculated in the background
// Should be two snapshots now.
snapshots = await balanceModel.find({ account: "Assets", book: book.name });
expect(snapshots.length).to.equal(2);
expect(snapshots[0].balance).to.equal(-1200);
expect(snapshots[1].balance).to.equal(-2400);
});
it("should not do balance snapshots if turned off", async () => {
const book = new Book("MyBook-balance-snapshot-off", { balanceSnapshotSec: 0 });
await addBalance(book);
await book.balance({ account: "Assets" });
const snapshots = await balanceModel.find({ account: "Assets", book: book.name });
expect(snapshots).to.have.length(0);
});
it("should reuse the snapshot balance in multi account query", async () => {
const book = new Book("MyBook-balance-multiple-snapshot");
await addBalance(book);
await book.balance({ account: ["Assets", "Income"] });
const snapshots = await balanceModel.find({ account: ["Assets", "Income"].join(), book: book.name });
expect(snapshots).to.have.length(1);
expect(snapshots[0].balance).to.equal(0);
snapshots[0].balance = 999;
await snapshots[0].save();
const data = await book.balance({ account: ["Assets", "Income"] });
expect(data.balance).to.equal(999);
});
it("should deal with JavaScript rounding weirdness", async function () {
const book = new Book("MyBook-balance-rounding");
await book.entry("Rounding Test").credit("A:B", 1005).debit("A:B", 994.95).debit("A:B", 10.05).commit();
const result1 = await book.balance({ account: "A:B" });
const { balance } = result1;
expect(balance).to.be.equal(0);
});
it("should have updated the balance for assets and income and accurately give balance for subaccounts", async () => {
const book = new Book("MyBook-balance-sub");
await addBalance(book);
{
const data = await book.balance({
account: "Assets",
});
const { notes, balance } = data;
expect(notes).to.be.equal(2);
expect(balance).to.be.equal(-1200);
}
{
const data1 = await book.balance({ account: "Assets:Receivable" });
const { notes, balance } = data1;
expect(balance).to.be.equal(-1200);
expect(notes).to.be.equal(2);
}
{
const data2 = await book.balance({
account: "Assets:Other",
});
const { notes, balance } = data2;
expect(balance).to.be.equal(0);
expect(notes).to.be.equal(0);
}
});
});
describe("journal.void", () => {
const book = new Book("MyBook-journal-void");
let journal:
| (Document &
IJournal & {
_original_journal?: Types.ObjectId;
})
| null = null;
before(async () => {
await book.entry("Test Entry").debit("Assets:Receivable", 700).credit("Income:Rent", 700).commit();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
journal = await book
.entry("Test Entry")
.debit("Assets:Receivable", 500, { clientId: "12345" })
.credit("Income:Rent", 500)
.commit();
});
it("should throw an JournalNotFoundError if journal does not exist", async () => {
try {
await book.void(new Types.ObjectId());
expect.fail("Should have thrown.");
} catch (e) {
expect(e).to.be.instanceOf(JournalNotFoundError);
}
});
it("should throw an JournalNotFoundError if journal does not exist in book", async () => {
const anotherBook = new Book("AnotherBook");
const anotherJournal = await anotherBook
.entry("Test Entry")
.debit("Assets:Receivable", 700)
.credit("Income:Rent", 700)
.commit();
try {
await book.void(anotherJournal._id);
expect.fail("Should have thrown.");
} catch (e) {
expect(e).to.be.instanceOf(JournalNotFoundError);
}
});
it("should allow you to void a journal entry", async () => {
if (!journal) {
expect.fail("journal missing.");
}
const data = await book.balance({
account: "Assets",
clientId: "12345",
});
expect(data.balance).to.be.equal(-500);
await book.void(journal._id, "Messed up");
const clientAccount = await book.balance({
account: "Assets",
clientId: "12345",
});
expect(clientAccount.balance).to.be.equal(0);
const data1 = await book.balance({
account: "Assets",
});
expect(data1.balance).to.be.equal(-700);
const data2 = await book.balance({
account: "Assets",
clientId: "12345",
});
expect(data2.balance).to.be.equal(0);
});
it("should throw an error if journal was already voided", async () => {
if (!journal) {
expect.fail("journal missing.");
}
try {
await book.void(journal._id, "Messed up");
expect.fail("Should have thrown.");
} catch (e) {
expect((e as Error).message).to.be.equal("Journal already voided.");
}
});
it("should create the correct memo fields when reason is given", async () => {
const journal = await book
.entry("Test Entry")
.debit("Assets:Receivable", 700)
.credit("Income:Rent", 700)
.commit();
const voidedJournal = await book.void(journal._id, "Void reason");
const updatedJournal = (await book.ledger({ _journal: journal._id })).results[0];
expect(updatedJournal.memo).to.be.equal("Test Entry");
expect(updatedJournal.void_reason).to.be.equal("Void reason");
expect(voidedJournal.memo).to.be.equal("Void reason");
expect(voidedJournal.void_reason).to.be.equal(undefined);
});
it("should create the correct memo fields when reason was not given", async () => {
const journal = await book
.entry("Test Entry")
.debit("Assets:Receivable", 700)
.credit("Income:Rent", 700)
.commit();
const voidedJournal = await book.void(journal._id);
const updatedJournal = (await book.ledger({ _journal: journal._id })).results[0];
expect(updatedJournal.memo).to.be.equal("Test Entry");
expect(updatedJournal.void_reason).to.be.equal("[VOID] Test Entry");
expect(voidedJournal.memo).to.be.equal("[VOID] Test Entry");
expect(voidedJournal.void_reason).to.be.equal(undefined);
});
it("should void string journal IDs", async () => {
const journal = await book
.entry("Test Entry")
.debit("Assets:Receivable", 700)
.credit("Income:Rent", 700)
.commit();
await book.void(journal._id.toString());
});
it("should have balance unchanged immediately after event date, when voiding event", async () => {
const justBeforeDate = new Date("2000-02-20T00:01:00.000Z");
const date = new Date("2000-02-20T00:02:00.000Z");
const justAfterDate = new Date("2000-02-20T00:03:00.000Z");
const oneDayAfter = new Date("2000-02-22T00:04:05.000Z");
const journal = await book
.entry("Test Entry", date)
.debit("Assets:ReceivableVoid", 700)
.credit("Income:RentVoid", 700)
.commit();
const balanceAfterEvent = await book.balance({
account: "Assets:ReceivableVoid",
start_date: justBeforeDate,
end_date: justAfterDate,
});
expect(balanceAfterEvent.balance).to.be.equal(-700);
await book.void(journal._id.toString());
// need to delete the balance snapshots to test the balance after as the query is similar
await balanceModel.deleteMany({ book: book.name });
const balanceAfterVoidingBeforeVoidingDate = await book.balance({
account: "Assets:ReceivableVoid",
start_date: justBeforeDate,
end_date: oneDayAfter,
});
expect(balanceAfterVoidingBeforeVoidingDate.balance).to.be.equal(-700);
await balanceModel.deleteMany({ book: book.name });
const balanceAfterVoidingAfterVoidingDate = await book.balance({ account: "Assets:ReceivableVoid" });
expect(balanceAfterVoidingAfterVoidingDate.balance).to.be.equal(0);
});
it("should have balance changed immediately after event date, when void journal keeps date of original journal", async () => {
const justBeforeDate = new Date("2000-02-20T00:01:00.000Z");
const date = new Date("2000-02-20T00:02:00.000Z");
const justAfterDate = new Date("2000-02-20T00:03:00.000Z");
const oneDayAfter = new Date("2000-02-22T00:04:05.000Z");
const journal = await book
.entry("Test Entry", date)
.debit("Assets:ReceivableVoid2", 700)
.credit("Income:RentVoid2", 700)
.commit();
const balanceBeforeVoiding = await book.balance({
account: "Assets:ReceivableVoid2",
start_date: justBeforeDate,
end_date: justAfterDate,
});
expect(balanceBeforeVoiding.balance).to.be.equal(-700);
await book.void(journal._id.toString(), undefined, undefined, true);
// need to delete the balance snapshots to test the balance after void
await balanceModel.deleteMany({ book: book.name });
const balanceAfterVoidingAfterEventDate = await book.balance({
account: "Assets:ReceivableVoid2",
start_date: justAfterDate,
end_date: oneDayAfter,
});
expect(balanceAfterVoidingAfterEventDate.balance).to.be.equal(0);
});
});
describe("listAccounts", () => {
it("should list all accounts", async () => {
const book = new Book("MyBook-listAccounts");
await book.entry("listAccounts test").credit("Assets:Receivable", 1).debit("Income:Rent", 1).commit();
const accounts = await book.listAccounts();
expect(accounts).to.have.lengthOf(4);
expect(accounts).to.have.members(["Assets", "Assets:Receivable", "Income", "Income:Rent"]);
});
it("should list accounts with 1 and 3 path parts", async () => {
const book = new Book("MyBook-listAccounts path parts");
await book.entry("listAccounts test 2").credit("Assets", 1).debit("Income:Rent:Taxable", 1).commit();
const accounts = await book.listAccounts();
expect(accounts).to.have.lengthOf(4);
expect(accounts).to.have.members(["Assets", "Income", "Income:Rent", "Income:Rent:Taxable"]);
});
it("should sort accounts alphabetically", async () => {
const book1 = new Book("MyBook-listAccounts sorting 1");
await book1.entry("MyBook-listAccounts sorting 1").debit("Income:Rent Taxable", 1).credit("Assets", 1).commit();
await book1.entry("MyBook-listAccounts sorting 1").credit("Liabilities", 1).debit("Client Custody", 1).commit();
await book1.entry("MyBook-listAccounts sorting 1").credit("Z", 1).debit("ZZ", 1).commit();
await book1.entry("MyBook-listAccounts sorting 1").credit("A", 1).debit("AA", 1).commit();
const accounts1 = await book1.listAccounts();
const book2 = new Book("MyBook-listAccounts sorting 2");
await book2.entry("MyBook-listAccounts sorting 2").debit("Client Custody", 2).credit("Liabilities", 2).commit();
await book2.entry("MyBook-listAccounts sorting 2").credit("Assets", 3).debit("Income:Rent Taxable", 3).commit();
await book2.entry("MyBook-listAccounts sorting 2").credit("Z", 3).debit("ZZ", 3).commit();
await book2.entry("MyBook-listAccounts sorting 2").credit("A", 3).debit("AA", 3).commit();
const accounts2 = await book2.listAccounts();
expect(accounts1).to.deep.equal(accounts2);
expect(accounts1).to.deep.equal([
"A",
"AA",
"Assets",
"Client Custody",
"Income",
"Income:Rent Taxable",
"Liabilities",
"Z",
"ZZ",
]);
});
async function addBalance(book: Book, suffix = "") {
await book
.entry("Test Entry")
.debit("Assets:Receivable" + suffix, 700, { clientId: "67890", otherProp: 1 })
.credit("Income:Rent" + suffix, 700)
.commit();
await book
.entry("Test Entry")
.debit("Assets:Receivable" + suffix, 500, { clientId: "12345", otherProp: 1 })
.credit("Income:Rent" + suffix, 500)
.commit();
}
it("should not do listAccounts snapshots if turned off", async () => {
const book = new Book("MyBook-balance-listAccounts-off", { balanceSnapshotSec: 0 });
await addBalance(book);
await book.listAccounts();
const snapshots = await balanceModel.find({ book: book.name });
expect(snapshots).to.have.length(0);
});
});
describe("ledger", () => {
const book = new Book("MyBook-ledger");
before(async () => {
await book.entry("ledger test 1").credit("Assets:Receivable", 1).debit("Income:Rent", 1).commit();
await book.entry("ledger test 2").debit("Income:Rent", 1).credit("Assets:Receivable", 1).commit();
await book.entry("ledger test 3").debit("Income:Rent", 1).credit("Assets:Receivable", 1).commit();
});
it("should return full ledger", async () => {
const res = await book.ledger({ account: "Assets" });
expect(res.results).to.have.lengthOf(3);
});
it("should return full ledger with hydrated objects when lean is not set", async () => {
const res = await book.ledger({ account: "Assets" });
expect(res.results).to.have.lengthOf(3);
expect(res.results[0]).to.not.have.property("_doc");
expect(res.results[1]).to.not.have.property("_doc");
expect(res.results[2]).to.not.have.property("_doc");
});
it("should return full ledger with just ObjectId of the _journal attribute", async () => {
const res = await book.ledger({ account: "Assets" });
expect(res.results).to.have.lengthOf(3);
expect(res.results[0]._journal).to.be.instanceof(Types.ObjectId);
expect(res.results[1]._journal).to.be.instanceof(Types.ObjectId);
expect(res.results[2]._journal).to.be.instanceof(Types.ObjectId);
});
it("should return ledger with array of accounts", async () => {
const res = await book.ledger({ account: ["Assets", "Income"] });
expect(res.results).to.have.lengthOf(6);
let assets = 0;
let income = 0;
for (const result of res.results) {
if (result.account_path.includes("Assets")) {
assets++;
}
if (result.account_path.includes("Income")) {
income++;
}
}
expect(assets).to.be.equal(3);
expect(income).to.be.equal(3);
});
it("should give you a paginated ledger when requested", async () => {
const response = await book.ledger({
account: ["Assets", "Income"],
perPage: 2,
page: 3,
});
expect(response.results).to.have.lengthOf(2);
expect(response.total).to.be.equal(6);
expect(response.results[0].memo).to.be.equal("ledger test 1");
expect(response.results[1].memo).to.be.equal("ledger test 1");
});
it("should give you a paginated ledger when requested and start by page 1 if page is not defined", async () => {
const response = await book.ledger({
account: ["Assets", "Income"],
perPage: 2,
});
expect(response.results).to.have.lengthOf(2);
expect(response.total).to.be.equal(6);
expect(response.results[0].memo).to.be.equal("ledger test 3");
expect(response.results[1].memo).to.be.equal("ledger test 3");
});
it("should give you a paginated ledger when requested and start by page 1 if page is defined", async () => {
const response = await book.ledger({
account: ["Assets", "Income"],
perPage: 2,
page: 1,
});
expect(response.results).to.have.lengthOf(2);
expect(response.total).to.be.equal(6);
expect(response.results[0].memo).to.be.equal("ledger test 3");
expect(response.results[1].memo).to.be.equal("ledger test 3");
});
it("should retrieve transactions by time range", async () => {
const book = new Book("MyBook_time_range");
await book
.entry("Test Entry")
.debit("Assets:Receivable", 500, { clientId: "12345" })
.credit("Income:Rent", 500)
.commit();
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
await book
.entry("Test Entry 2", threeDaysAgo)
.debit("Assets:Receivable", 700)
.credit("Income:Rent", 700)
.commit();
const fourDaysAgo = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000);
const endDate = new Date(); // today
const { total } = await book.ledger({
account: "Income",
start_date: fourDaysAgo,
end_date: endDate,
});
expect(total).to.be.equal(2);
});
});
});
================================================
FILE: spec/constructKey.spec.ts
================================================
import { expect } from "chai";
import { constructKey } from "../src/models/balance";
describe("constructKey", () => {
it("should handle empty account and meta", () => {
const result = constructKey("MyBook");
expect(result).to.be.equal("MyBook");
});
it("should handle empty meta", () => {
const result = constructKey("MyBook", "Account");
expect(result).to.be.equal("MyBook;Account");
});
it("should handle meta with same keys but different order", () => {
const resultKey = "MyBook;Account;clientId.$in.0:12345,clientId.$in.1:67890,currency:USD";
const result1 = constructKey("MyBook", "Account", { currency: "USD", clientId: { $in: ["12345", "67890"] } });
expect(result1).to.be.equal(resultKey);
const result2 = constructKey("MyBook", "Account", { clientId: { $in: ["12345", "67890"] }, currency: "USD" });
expect(result2).to.be.equal(resultKey);
expect(result1).to.equal(result2);
});
});
================================================
FILE: spec/extractObjectIdKeysFromSchema.spec.ts
================================================
import { expect } from "chai";
import { Schema } from "mongoose";
import { extractObjectIdKeysFromSchema } from "../src/helper/extractObjectIdKeysFromSchema";
describe("extractObjectIdKeysFromSchema", () => {
it("should get an array of the ObjectId-fields", () => {
const testSchema = new Schema({
_journal: Schema.Types.ObjectId,
test: String,
});
const result = extractObjectIdKeysFromSchema(testSchema);
expect(result.size).be.equal(2);
expect(result.has("_id")).to.be.true;
expect(result.has("_journal")).to.be.true;
expect(result.has("test")).to.be.false;
});
});
================================================
FILE: spec/fpPrecision.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off, no-prototype-builtins: off*/
import { expect } from "chai";
import { Book } from "../src";
describe("fpPrecision", function () {
describe("fpPrecision - default: fp-Mode 8", () => {
it("should store a commit without errors", async function () {
const book = new Book("MyBook-fpPrecision-8");
await book
.entry("Test fp")
.credit("Assets:Receivable", 0.1)
.credit("Assets:Receivable", 0.2)
.debit("Income:Rent", 0.1)
.debit("Income:Rent", 0.2)
.commit();
const result1 = await book.balance({
account: "Assets:Receivable",
});
expect(result1.notes).to.be.equal(2);
expect(result1.balance).to.be.equal(0.3);
const result2 = await book.balance({
account: "Income:Rent",
});
expect(result2.notes).to.be.equal(2);
expect(result2.balance).to.be.equal(-0.3);
});
});
describe("fpPrecision - fp-Mode 7", () => {
it("should store a commit without errors", async function () {
const book = new Book("MyBook-fpPrecision-7", { precision: 7 });
await book
.entry("Test fp")
.credit("Assets:Receivable", 0.0000001)
.credit("Assets:Receivable", 0.0000002)
.debit("Income:Rent", 0.0000001)
.debit("Income:Rent", 0.0000002)
.debit("Assets:Rent", 0.0000003)
.credit("Assets:Rent", 0.0000001)
.credit("Assets:Black", 0.0000002)
.commit();
const result1 = await book.balance({
account: "Assets:Receivable",
});
expect(result1.notes).to.be.equal(2);
expect(result1.balance).to.be.equal(0.0000003);
const result2 = await book.balance({
account: "Income:Rent",
});
expect(result2.notes).to.be.equal(2);
expect(result2.balance).to.be.equal(-0.0000003);
const result3 = await book.balance({
account: "Assets:Rent",
});
expect(result3.notes).to.be.equal(2);
expect(result3.balance).to.be.equal(-0.0000002);
});
});
describe("fpPrecision - integer-Mode", () => {
it("should store a journal without error", async function () {
const book = new Book("MyBook-integer", { precision: 0 });
await book
.entry("Test fp")
.credit("Assets:Receivable", 1.1)
.credit("Assets:Receivable", 1.2)
.debit("Income:Rent", 1.1)
.debit("Income:Rent", 1.2)
.commit();
const result1 = await book.balance({
account: "Assets:Receivable",
});
expect(result1.notes).to.be.equal(2);
expect(result1.balance).to.be.equal(2);
const result2 = await book.balance({
account: "Income:Rent",
});
expect(result2.notes).to.be.equal(2);
expect(result2.balance).to.be.equal(-2);
});
});
});
================================================
FILE: spec/handleVoidMemo.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off, @typescript-eslint/no-non-null-assertion: off, security/detect-object-injection: off */
import { expect } from "chai";
import { handleVoidMemo } from "../src/helper/handleVoidMemo";
describe("handleVoidMemo", () => {
const cases = [
["should passthrough reason", "reason", "memo", "reason"],
["should handle no specially tagged memo when no reason was provided", undefined, "memo", "[VOID] memo"],
["should handle unvoiding", undefined, "[VOID] memo", "[UNVOID] memo"],
["should handle revoiding", undefined, "[UNVOID] memo", "[REVOID] memo"],
["should handle unvoiding a revoided memo", undefined, "[REVOID] memo", "[UNVOID] memo"],
["should handle no reason and no memo", undefined, undefined, "[VOID]"],
];
for (const c of cases) {
it(c[0]!, () => {
expect(handleVoidMemo(c[1], c[2])).to.be.equal(c[3]);
});
}
});
================================================
FILE: spec/helper/MongoDB.spec.ts
================================================
import { before, after } from "mocha";
import * as mongoose from "mongoose";
import { MongoMemoryReplSet } from "mongodb-memory-server";
let replSet: MongoMemoryReplSet;
if (process.env.USE_MEMORY_REPL_SET !== "true") {
if (process.env.CI) {
// The GitHub's MongoDB server is a Replica Set. Thus supports ACID.
process.env.ACID_AVAILABLE = "true";
}
} else {
process.env.ACID_AVAILABLE = "true";
}
before(async function () {
this.timeout(40000);
if (process.env.USE_MEMORY_REPL_SET !== "true") {
await mongoose.connect("mongodb://localhost/medici_test", { serverSelectionTimeoutMS: 2500 });
// Cleanup if there are any leftovers from the previous runs. Useful in local development.
const db = mongoose.connection.db;
await db.collection("medici_transactions").deleteMany({});
await db.collection("medici_journals").deleteMany({});
await db.collection("medici_locks").deleteMany({});
await db.collection("medici_balances").deleteMany({});
} else {
replSet = new MongoMemoryReplSet({
binary: {
version: "4.4.0",
},
instanceOpts: [
// Set the expiry job in MongoDB to run every second
{ args: ["--setParameter", "ttlMonitorSleepSecs=1"] },
],
replSet: {
name: "rs0",
storageEngine: "wiredTiger",
},
});
await replSet.start();
await replSet.waitUntilRunning();
const connectionString = replSet.getUri("medici_test");
await mongoose.connect(connectionString);
}
});
after(async () => {
await mongoose.disconnect();
if (replSet) {
await replSet.stop();
}
});
================================================
FILE: spec/helper/delay.ts
================================================
export default (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
================================================
FILE: spec/helper/transactionSchema.ts
================================================
import { Schema, Types } from "mongoose";
import { IAnyObject } from "../../src/IAnyObject";
export interface ITransactionTest {
_id?: Types.ObjectId;
credit: number;
debit: number;
meta?: IAnyObject;
datetime: Date;
account_path: string[];
accounts: string;
book: string;
memo: string;
_journal: Types.ObjectId;
timestamp: Date;
voided?: boolean;
void_reason?: string;
_original_journal?: Types.ObjectId;
// custom attributes
_journal2: Types.ObjectId;
clientId?: string;
}
export function getTransactionSchemaTest() {
const transactionSchemaTest = new Schema<ITransactionTest>(
{
credit: Number,
debit: Number,
meta: Schema.Types.Mixed,
datetime: Date,
account_path: [String],
accounts: String,
book: String,
memo: String,
_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
timestamp: Date,
voided: Boolean,
void_reason: String,
// The journal that this is voiding, if any
_original_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
// custom attributes
_journal2: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
clientId: String,
},
{ id: false, versionKey: false, timestamps: false }
);
transactionSchemaTest.index({
voided: 1,
void_reason: 1,
});
return transactionSchemaTest;
}
================================================
FILE: spec/index.spec.ts
================================================
import "./helper/MongoDB.spec";
================================================
FILE: spec/parseBalanceQuery.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off, @typescript-eslint/no-non-null-assertion: off */
import { expect } from "chai";
import { Types } from "mongoose";
import * as sinon from "sinon";
import { parseBalanceQuery } from "../src/helper/parse/parseBalanceQuery";
import * as Transaction from "../src/models/transaction";
describe("parseBalanceQuery", () => {
it("should handle empty object and book name correctly", () => {
const result = parseBalanceQuery({}, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(1);
expect(result.book).to.be.equal("MyBook");
});
it("should put _journal string as string to meta", () => {
const _journal = new Types.ObjectId().toString();
const result = parseBalanceQuery({ _journal }, { name: "MyBook" });
expect(result).to.deep.equal({
book: "MyBook",
_journal: new Types.ObjectId(_journal),
meta: { _journal },
});
});
it("should put _journal ObjectId as ObjectId to meta", () => {
const _journal = new Types.ObjectId();
const result = parseBalanceQuery({ _journal }, { name: "MyBook" });
expect(result).to.deep.equal({
book: "MyBook",
_journal: new Types.ObjectId(_journal),
meta: { _journal: new Types.ObjectId(_journal) },
});
});
it("should handle start_date correctly", () => {
const start_date = new Date(666);
const result = parseBalanceQuery({ start_date }, { name: "MyBook" });
expect(result).to.deep.equal({
book: "MyBook",
datetime: {
$gte: new Date(666),
},
});
});
it("should handle end_date correctly", () => {
const end_date = new Date(999);
const result = parseBalanceQuery({ end_date }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result.datetime["$lte"]).to.be.instanceOf(Date);
expect(result.datetime["$lte"].getTime()).to.be.equal(999);
});
it("should handle start_date and end_date correctly", () => {
const start_date = new Date(666);
const end_date = new Date(999);
const result = parseBalanceQuery({ start_date, end_date }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result.datetime).to.have.property("$lte");
expect(result.datetime).to.have.property("$gte");
expect(result.datetime["$gte"]).to.be.instanceOf(Date);
expect(result.datetime["$gte"].getTime()).to.be.equal(666);
expect(result.datetime["$lte"]).to.be.instanceOf(Date);
expect(result.datetime["$lte"].getTime()).to.be.equal(999);
});
it("should handle meta correctly", () => {
const clientId = "619af485cd56547936847584";
let bookmarked = true;
const result1 = parseBalanceQuery({ clientId, bookmarked }, { name: "MyBook" });
expect(result1).to.deep.equal({
book: "MyBook",
"meta.clientId": clientId,
"meta.bookmarked": bookmarked,
meta: { clientId, bookmarked },
});
sinon.stub(Transaction, "isValidTransactionKey").callsFake((value: unknown) => value === "clientId");
const result2 = parseBalanceQuery({ clientId, bookmarked }, { name: "MyBook" });
expect(result2).to.deep.equal({
book: "MyBook",
clientId,
"meta.bookmarked": bookmarked,
meta: { clientId, bookmarked },
});
sinon.restore();
bookmarked = false;
const _someOtherDatabaseId = "619af485cd56547936847584";
const result3 = parseBalanceQuery({ clientId, _someOtherDatabaseId, bookmarked }, { name: "MyBook" });
expect(result3).to.deep.equal({
book: "MyBook",
"meta.clientId": clientId,
"meta.bookmarked": bookmarked,
"meta._someOtherDatabaseId": _someOtherDatabaseId,
meta: { clientId, bookmarked, _someOtherDatabaseId },
});
});
it("should handle account with one path part correctly", () => {
const account = "Assets";
const result = parseBalanceQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["account_path.0"]).to.be.equal("Assets");
});
it("should handle account with two path parts correctly", () => {
const account = "Assets:Gold";
const result = parseBalanceQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(3);
expect(result.book).to.be.equal("MyBook");
expect(result["account_path.0"]).to.be.equal("Assets");
expect(result["account_path.1"]).to.be.equal("Gold");
});
it("should handle account with two path parts and maxAccountPath = 2 correctly", () => {
const account = "Assets:Gold";
const result = parseBalanceQuery({ account }, { name: "MyBook", maxAccountPath: 2 });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result.accounts).to.be.equal("Assets:Gold");
});
it("should handle account with three path parts correctly", () => {
const account = "Assets:Gold:Swiss";
const result = parseBalanceQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result.accounts).to.be.equal("Assets:Gold:Swiss");
});
it("should handle account array with one path part correctly", () => {
const account = ["Assets", "Expenses"];
const result = parseBalanceQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["$or"]).to.have.lengthOf(2);
expect(Object.keys(result["$or"]![0])).to.have.lengthOf(1);
expect(Object.keys(result["$or"]![1])).to.have.lengthOf(1);
expect(result["$or"]![0]["account_path.0"]).to.be.equal("Assets");
expect(result["$or"]![1]["account_path.0"]).to.be.equal("Expenses");
});
it("should handle account array with two path parts correctly", () => {
const account = ["Assets:Gold", "Expenses:Gold"];
const result = parseBalanceQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["$or"]).to.have.lengthOf(2);
expect(Object.keys(result["$or"]![0])).to.have.lengthOf(2);
expect(Object.keys(result["$or"]![1])).to.have.lengthOf(2);
expect(result["$or"]![0]["account_path.0"]).to.be.equal("Assets");
expect(result["$or"]![0]["account_path.1"]).to.be.equal("Gold");
expect(result["$or"]![1]["account_path.0"]).to.be.equal("Expenses");
expect(result["$or"]![1]["account_path.1"]).to.be.equal("Gold");
});
it("should handle account array with three path parts correctly", () => {
const account = ["Assets:Gold:Swiss", "Expenses:Gold:Swiss"];
const result = parseBalanceQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["$or"]).to.have.lengthOf(2);
expect(Object.keys(result["$or"]![0])).to.have.lengthOf(1);
expect(Object.keys(result["$or"]![1])).to.have.lengthOf(1);
expect(result["$or"]![0]["accounts"]).to.be.equal("Assets:Gold:Swiss");
expect(result["$or"]![1]["accounts"]).to.be.equal("Expenses:Gold:Swiss");
});
it("should handle account array with one item and two path parts correctly", () => {
const account = ["Assets:Gold"];
const result = parseBalanceQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(3);
expect(result.book).to.be.equal("MyBook");
expect(result["account_path.0"]).to.be.equal("Assets");
expect(result["account_path.1"]).to.be.equal("Gold");
});
it("should handle account array with one item and three path parts correctly", () => {
const account = ["Assets:Gold:Swiss"];
const result = parseBalanceQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["accounts"]).to.be.equal("Assets:Gold:Swiss");
});
it("should handle potential prototype injection correctly", () => {
const result = parseBalanceQuery({ toString: "a" }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(1);
expect(result.book).to.be.equal("MyBook");
});
});
================================================
FILE: spec/parseDateField.spec.ts
================================================
/* eslint @typescript-eslint/no-non-null-assertion: off */
import { expect } from "chai";
import { parseDateField } from "../src/helper/parse/parseDateField";
import * as moment from "moment";
import { DateTime } from "luxon";
describe("parseDateField", () => {
it("should passthrough Date-Objects", () => {
const date = new Date();
const parsedDate = parseDateField(date);
expect(parsedDate).to.be.instanceOf(Date);
expect(parsedDate).to.be.equal(date);
});
it("should handle numbers as unix timestamps", () => {
const parsedDate = parseDateField(50)!;
expect(parsedDate).to.be.instanceOf(Date);
expect(parsedDate.getTime()).to.be.equal(50);
});
it("should handle strings of numbers as unix timestamps", () => {
const parsedDate = parseDateField("50")!;
expect(parsedDate).to.be.instanceOf(Date);
expect(parsedDate.getTime()).to.be.equal(50);
});
it("should handle strings which are not pure numbers gracefully", () => {
const date = new Date(1639577227000);
const parsedDate = parseDateField(date.toUTCString())!;
expect(parsedDate).to.be.instanceOf(Date);
expect(parsedDate.getTime()).to.be.equal(date.getTime());
});
it("should handle moment.js, luxon and similar libraries", () => {
const m = moment();
const parsedDate1 = parseDateField(m)!;
expect(parsedDate1).to.be.instanceOf(Date);
// Unfortunately, automatic conversion from moment looses milliseconds.
// We should consider throwing exceptions in the next big breaking release.
expect(Math.floor(parsedDate1.getTime() / 1000)).to.be.equal(m.unix());
const l = DateTime.now();
const parsedDate2 = parseDateField(l)!;
expect(parsedDate2).to.be.instanceOf(Date);
// Unfortunately, automatic conversion from moment looses milliseconds.
// We should consider throwing exceptions in the next big breaking release.
expect(Math.floor(parsedDate2.getTime() / 1000)).to.be.equal(Math.floor(l.toSeconds()));
});
it("should return undefined if it is not parsable", () => {
const date = true;
const parsedDate = parseDateField(date);
expect(parsedDate).to.be.undefined;
});
});
================================================
FILE: spec/parseFilterQuery.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off, @typescript-eslint/no-non-null-assertion: off */
import { expect } from "chai";
import { Types } from "mongoose";
import { parseFilterQuery } from "../src/helper/parse/parseFilterQuery";
import { parseBalanceQuery } from "../src/helper/parse/parseBalanceQuery";
describe("parseFilterQuery", () => {
it("should handle empty object and book name correctly", () => {
const result = parseFilterQuery({}, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(1);
expect(result.book).to.be.equal("MyBook");
});
it("should handle _journal string correctly", () => {
const _journal = new Types.ObjectId().toString();
const result = parseFilterQuery({ _journal }, { name: "MyBook" });
expect(result).to.deep.equal({
book: "MyBook",
_journal: new Types.ObjectId(_journal),
});
});
it("should handle _journal ObjectId correctly", () => {
const _journal = new Types.ObjectId();
const result = parseFilterQuery({ _journal }, { name: "MyBook" });
expect(result).to.deep.equal({
book: "MyBook",
_journal: new Types.ObjectId(_journal),
});
});
it("should handle start_date correctly", () => {
const start_date = new Date(666);
const result = parseFilterQuery({ start_date }, { name: "MyBook" });
expect(result).to.deep.equal({
book: "MyBook",
datetime: {
$gte: new Date(666),
},
});
});
it("should handle end_date correctly", () => {
const end_date = new Date(999);
const result = parseFilterQuery({ end_date }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result.datetime["$lte"]).to.be.instanceOf(Date);
expect(result.datetime["$lte"].getTime()).to.be.equal(999);
});
it("should handle start_date and end_date correctly", () => {
const start_date = new Date(666);
const end_date = new Date(999);
const result = parseFilterQuery({ start_date, end_date }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result.datetime).to.have.property("$lte");
expect(result.datetime).to.have.property("$gte");
expect(result.datetime["$gte"]).to.be.instanceOf(Date);
expect(result.datetime["$gte"].getTime()).to.be.equal(666);
expect(result.datetime["$lte"]).to.be.instanceOf(Date);
expect(result.datetime["$lte"].getTime()).to.be.equal(999);
});
it("should handle meta correctly", () => {
const clientId = "619af485cd56547936847584";
let bookmarked = true;
const result1 = parseBalanceQuery({ clientId, bookmarked }, { name: "MyBook" });
expect(result1).to.deep.equal({
book: "MyBook",
"meta.bookmarked": bookmarked,
"meta.clientId": clientId,
meta: { clientId, bookmarked },
});
bookmarked = false;
const _someOtherDatabaseId = "619af485cd56547936847584";
const result2 = parseFilterQuery({ _someOtherDatabaseId, bookmarked }, { name: "MyBook" });
expect(result2).to.deep.equal({
book: "MyBook",
"meta._someOtherDatabaseId": "619af485cd56547936847584",
"meta.bookmarked": false,
});
});
it("should handle account with one path part correctly", () => {
const account = "Assets";
const result = parseFilterQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["account_path.0"]).to.be.equal("Assets");
});
it("should handle account with two path parts correctly", () => {
const account = "Assets:Gold";
const result = parseFilterQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(3);
expect(result.book).to.be.equal("MyBook");
expect(result["account_path.0"]).to.be.equal("Assets");
expect(result["account_path.1"]).to.be.equal("Gold");
});
it("should handle account with two path parts and maxAccountPath = 2 correctly", () => {
const account = "Assets:Gold";
const result = parseFilterQuery({ account }, { name: "MyBook", maxAccountPath: 2 });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result.accounts).to.be.equal("Assets:Gold");
});
it("should handle account with three path parts correctly", () => {
const account = "Assets:Gold:Swiss";
const result = parseFilterQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result.accounts).to.be.equal("Assets:Gold:Swiss");
});
it("should handle account array with one path part correctly", () => {
const account = ["Assets", "Expenses"];
const result = parseFilterQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["$or"]).to.have.lengthOf(2);
expect(Object.keys(result["$or"]![0])).to.have.lengthOf(1);
expect(Object.keys(result["$or"]![1])).to.have.lengthOf(1);
expect(result["$or"]![0]["account_path.0"]).to.be.equal("Assets");
expect(result["$or"]![1]["account_path.0"]).to.be.equal("Expenses");
});
it("should handle account array with two path parts correctly", () => {
const account = ["Assets:Gold", "Expenses:Gold"];
const result = parseFilterQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["$or"]).to.have.lengthOf(2);
expect(Object.keys(result["$or"]![0])).to.have.lengthOf(2);
expect(Object.keys(result["$or"]![1])).to.have.lengthOf(2);
expect(result["$or"]![0]["account_path.0"]).to.be.equal("Assets");
expect(result["$or"]![0]["account_path.1"]).to.be.equal("Gold");
expect(result["$or"]![1]["account_path.0"]).to.be.equal("Expenses");
expect(result["$or"]![1]["account_path.1"]).to.be.equal("Gold");
});
it("should handle account array with three path parts correctly", () => {
const account = ["Assets:Gold:Swiss", "Expenses:Gold:Swiss"];
const result = parseFilterQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["$or"]).to.have.lengthOf(2);
expect(Object.keys(result["$or"]![0])).to.have.lengthOf(1);
expect(Object.keys(result["$or"]![1])).to.have.lengthOf(1);
expect(result["$or"]![0]["accounts"]).to.be.equal("Assets:Gold:Swiss");
expect(result["$or"]![1]["accounts"]).to.be.equal("Expenses:Gold:Swiss");
});
it("should handle account array with one item and two path parts correctly", () => {
const account = ["Assets:Gold"];
const result = parseFilterQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(3);
expect(result.book).to.be.equal("MyBook");
expect(result["account_path.0"]).to.be.equal("Assets");
expect(result["account_path.1"]).to.be.equal("Gold");
});
it("should handle account array with one item and three path parts correctly", () => {
const account = ["Assets:Gold:Swiss"];
const result = parseFilterQuery({ account }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(2);
expect(result.book).to.be.equal("MyBook");
expect(result["accounts"]).to.be.equal("Assets:Gold:Swiss");
});
it("should handle potential prototype injection correctly", () => {
const result = parseFilterQuery({ toString: "a" }, { name: "MyBook" });
expect(Object.keys(result)).to.have.lengthOf(1);
expect(result.book).to.be.equal("MyBook");
});
});
================================================
FILE: spec/safeSetKeyToMetaObject.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off */
import { expect } from "chai";
import { Schema, Types } from "mongoose";
import { IAnyObject } from "../src/IAnyObject";
import { setTransactionSchema, transactionSchema } from "../src/models/transaction";
import { safeSetKeyToMetaObject } from "../src/helper/safeSetKeyToMetaObject";
export interface ITransactionNew {
_id?: Types.ObjectId;
credit: number;
debit: number;
meta?: IAnyObject;
datetime: Date;
account_path: string[];
accounts: string;
book: string;
memo: string;
_journal: Types.ObjectId;
timestamp: Date;
voided?: boolean;
void_reason?: string;
_original_journal?: Types.ObjectId;
customField: string;
}
describe("safeSetKeyToMetaObject", () => {
before(function () {
const transactionSchemaNew = new Schema<ITransactionNew>(
{
credit: Number,
debit: Number,
meta: Schema.Types.Mixed,
datetime: Date,
account_path: [String],
accounts: String,
book: String,
memo: String,
_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
timestamp: Date,
voided: Boolean,
void_reason: String,
// The journal that this is voiding, if any
_original_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
customField: String,
},
{ id: false, versionKey: false, timestamps: false }
);
setTransactionSchema(transactionSchemaNew, undefined, { defaultIndexes: false });
});
after(function () {
setTransactionSchema(transactionSchema);
});
it("should set a custom schema attribute", function () {
const newMeta: IAnyObject = {};
safeSetKeyToMetaObject("customField", "value", newMeta);
expect(Object.keys(newMeta)).to.have.lengthOf(1);
expect(newMeta).to.be.eql({ customField: "value" });
});
it("should set a meta attribute", function () {
const newMeta: IAnyObject = {};
safeSetKeyToMetaObject("anotherField", "value", newMeta);
expect(Object.keys(newMeta)).to.have.lengthOf(1);
expect(newMeta).to.be.eql({ anotherField: "value" });
});
it("should set custom and meta attributes", function () {
const newMeta: IAnyObject = {};
safeSetKeyToMetaObject("customField", "value", newMeta);
safeSetKeyToMetaObject("anotherField", "value", newMeta);
expect(Object.keys(newMeta)).to.have.lengthOf(2);
expect(newMeta).to.be.eql({ customField: "value", anotherField: "value" });
});
it("should not set prototype attributes", function () {
const newMeta: IAnyObject = {};
safeSetKeyToMetaObject("__proto__", "value", newMeta);
safeSetKeyToMetaObject("__defineGetter__", "value", newMeta);
safeSetKeyToMetaObject("__lookupGetter__", "value", newMeta);
safeSetKeyToMetaObject("__defineSetter__", "value", newMeta);
safeSetKeyToMetaObject("__lookupSetter__", "value", newMeta);
safeSetKeyToMetaObject("constructor", "value", newMeta);
safeSetKeyToMetaObject("hasOwnProperty", "value", newMeta);
safeSetKeyToMetaObject("isPrototypeOf", "value", newMeta);
safeSetKeyToMetaObject("propertyIsEnumerable", "value", newMeta);
safeSetKeyToMetaObject("toString", "value", newMeta);
safeSetKeyToMetaObject("toLocaleString", "value", newMeta);
safeSetKeyToMetaObject("valueOf", "value", newMeta);
expect(Object.keys(newMeta)).to.have.lengthOf(0);
});
it("should not set original schema attributes", function () {
const newMeta: IAnyObject = {};
Object.keys(transactionSchema.paths).forEach((key) => {
safeSetKeyToMetaObject(key, "value", newMeta);
});
expect(Object.keys(newMeta)).to.have.lengthOf(0);
});
});
================================================
FILE: spec/setTransactionSchema.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off */
import { expect } from "chai";
import { Types } from "mongoose";
import { Book, syncIndexes } from "../src";
import { setTransactionSchema, transactionModel, transactionSchema } from "../src/models/transaction";
import { getTransactionSchemaTest, ITransactionTest } from "./helper/transactionSchema";
describe("setTransactionSchema", () => {
it("should return full ledger with _journal2", async function () {
this.timeout(10000);
await syncIndexes({ background: false });
try {
const transactionSchemaTest = getTransactionSchemaTest();
setTransactionSchema(transactionSchemaTest, undefined, {
defaultIndexes: false,
});
const diffIndexesBefore = await transactionModel.diffIndexes();
expect(diffIndexesBefore.toDrop).to.have.lengthOf(3);
expect(diffIndexesBefore.toCreate[0]).to.be.deep.equal({
voided: 1,
void_reason: 1,
});
const book = new Book<ITransactionTest>("MyBook-TransactionSchema");
const journal = await book
.entry("Test")
.credit("Assets:Receivable", 1)
.credit("Assets:Receivable", 2)
.debit("Income:Rent", 1)
.debit("Income:Rent", 2)
.commit();
await book
.entry("Test fp")
.credit("Cars", 1, { _journal2: journal._id })
.debit("Cars", 1, { _journal2: journal._id })
.commit();
const res = await book.ledger({ account: "Cars" });
expect(res.results).to.have.lengthOf(2);
expect(res.results[0]._journal2._id).to.be.instanceof(Types.ObjectId);
expect(res.results[1]._journal2._id).to.be.instanceof(Types.ObjectId);
expect(res.results[0]._journal2._id.toString()).to.be.equal(journal._id.toString());
expect(res.results[1]._journal2._id.toString()).to.be.equal(journal._id.toString());
} finally {
setTransactionSchema(transactionSchema);
await syncIndexes({ background: false });
}
});
});
================================================
FILE: spec/types/medici.spec-d.ts
================================================
/* eslint import/no-unresolved: off */
import { expectError, expectType } from "tsd";
import BookESM, { Book, Entry, setJournalSchema, setTransactionSchema } from "../../types/index";
import { Types, ClientSession } from "mongoose";
import { ITransaction } from "../../src/models/transaction";
expectType<Book>(new BookESM("MyBook"));
expectType<Book>(new Book("MyBook"));
expectType<Book>(new Book("MyBook", {}));
expectType<Book>(new Book("MyBook", { precision: 7 }));
expectError(new Book());
expectError(new Book("MyBook", ""));
expectError(new Book("MyBook", 7));
expectError(new Book("MyBook", { precision: "7" }));
expectError(new Book("MyBook", { precision: true }));
expectError(setJournalSchema());
expectError(setTransactionSchema());
const book = new Book("MyBook");
expectError(book.entry());
expectType<Entry>(book.entry("a memo"));
expectType<Entry>(book.entry("a memo", undefined, new Types.ObjectId()));
expectType<Entry>(book.entry("a memo", new Date(), undefined));
expectType<Entry>(book.entry("a memo", new Date(), new Types.ObjectId()));
expectType<Entry>(book.entry("a memo", new Date(), "123456789012345678901234"));
expectError(book.entry("a memo").credit());
expectError(book.entry("a memo").credit("Assets"));
expectType<Entry>(book.entry("a memo").credit("Assets", 200));
expectType<Entry>(book.entry("a memo").credit("Assets", 200, {}));
expectError(book.entry("a memo").credit("Assets", 200, "invalid"));
expectType<Entry>(book.entry("a memo").credit("Assets", 200, { fieldA: "aaa" }));
expectError(book.entry("a memo").credit("Assets", 200, { credit: "aaa" }));
expectType<Entry>(book.entry("a memo").credit<{ fieldA: string }>("Assets", 200, { fieldA: "aaa" }));
expectType<Entry>(book.entry("a memo").credit<{ fieldA: string }>("Assets", 200, { fieldA: "aaa", credit: 2 }));
expectError(book.entry("a memo").credit<{ fieldA: string }>("Assets", 200, { fieldB: "aaa" }));
async () => {
expectType<{ results: ITransaction[]; total: number }>(await book.ledger({}));
expectType<{ results: ITransaction[]; total: number }>(
await book.ledger({}, { session: null as unknown as ClientSession })
);
};
================================================
FILE: spec/xacid.spec.ts
================================================
/* eslint sonarjs/no-duplicate-string: off, sonarjs/no-identical-functions: off */
import { Book } from "../src/Book";
import { expect } from "chai";
import * as mongoose from "mongoose";
import { initModels, mongoTransaction } from "../src";
import { lockModel } from "../src/models/lock";
import delay from "./helper/delay";
if (process.env.ACID_AVAILABLE) {
describe("acid", function () {
before(async () => {
await initModels();
});
it("should not persist data when saving journal fails while using a session", async function () {
const book = new Book("ACID" + Date.now());
try {
await mongoose.connection.transaction(async (session) => {
await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
// @ts-expect-error mongoose validator should throw an error
.credit("X:Y:CHF", 1, { datetime: "invalid" })
.debit("CashAssets", 5)
.commit({ session });
});
expect.fail("Should have thrown.");
} catch (e) {
expect((e as Error).message).match(/Medici_Transaction validation failed/);
}
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(0);
});
it("check if mongoTransaction is working as an alias", async function () {
const book = new Book("ACID" + Date.now());
try {
await mongoTransaction(async (session) => {
await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
// @ts-expect-error mongoose validator should throw an error
.credit("X:Y:CHF", 1, { datetime: "invalid" })
.debit("CashAssets", 5)
.commit({ session });
});
expect.fail("Should have thrown.");
} catch (e) {
expect((e as Error).message).match(/Medici_Transaction validation failed/);
}
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(0);
});
it("should persist data while using a session", async function () {
const book = new Book("ACID" + Date.now());
await mongoose.connection.transaction(async (session) => {
await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
.credit("X:Y:CHF", 1)
.debit("CashAssets", 5)
.commit({ session });
});
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(5);
});
it("should not persist data if we throw an Error while using a session", async function () {
const book = new Book("ACID" + Date.now());
try {
await mongoose.connection.transaction(async (session) => {
await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
.credit("X:Y:CHF", 1)
.debit("CashAssets", 5)
.commit({ session });
const { balance } = await book.balance(
{
account: "X:Y:CHF",
},
{ session }
);
if (balance <= 2) {
throw new Error("Not enough Balance.");
}
});
} catch (e) {
expect((e as Error).message).to.be.equal("Not enough Balance.");
}
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(0);
});
it("should pass a stresstest when persisting data while using a session", async function () {
this.timeout(10000);
for (let i = 0; i < 100; i++) {
const book = new Book("ACID" + Date.now());
await mongoose.connection.transaction(async (session) => {
await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
.credit("X:Y:CHF", 1)
.debit("CashAssets", 5)
.commit({ session });
});
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(5);
}
});
it("should pass a stresstest when voiding while using a session", async function () {
this.timeout(10000);
for (let i = 0; i < 100; i++) {
const book = new Book("ACID" + Date.now());
const journal = await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
.credit("X:Y:CHF", 1)
.debit("CashAssets", 5)
.commit();
await mongoose.connection.transaction(async (session) => {
await book.void(journal._id, null, { session });
});
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(0);
}
});
it("should pass a stresstest for erroring when committing", async function () {
this.timeout(10000);
const book = new Book("ACID" + Date.now());
for (let i = 0; i < 100; i++) {
try {
await mongoose.connection.transaction(async (session) => {
await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
.credit("X:Y:CHF", 1)
.debit("CashAssets", 5)
.commit({ session });
const { balance } = await book.balance(
{
account: "X:Y:CHF",
},
{ session }
);
if (balance <= 2) {
throw new Error("Not enough Balance.");
}
});
} catch (e) {
expect((e as Error).message).to.be.equal("Not enough Balance.");
}
}
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(0);
});
it("should pass a stresstest for erroring when voiding", async function () {
this.timeout(10000);
const book = new Book("ACID" + Date.now());
const journal = await book
.entry("depth test")
.credit("X:Y:AUD", 1)
.credit("X:Y:EUR", 1)
.credit("X:Y:USD", 1)
.credit("X:Y:INR", 1)
.credit("X:Y:CHF", 1)
.debit("CashAssets", 5)
.commit();
for (let i = 0; i < 100; i++) {
try {
await mongoose.connection.transaction(async (session) => {
await book.void(journal._id, null, { session });
throw new Error("Journaling failed.");
});
} catch (e) {
expect((e as Error).message).to.be.equal("Journaling failed.");
}
journal.voided = false;
}
const result = await book.balance({ account: "X:Y" });
expect(result.balance).to.be.equal(5);
});
it("should avoid double spending, commit() using writelockAccounts", async function () {
const book = new Book("ACID" + Date.now());
await book.entry("depth test").credit("Income", 2).debit("Outcome", 2).commit();
async function spendOne(session: mongoose.ClientSession, name: string, pause: number) {
await book
.entry("depth test")
.credit("Savings", 1)
.debit("Income", 1)
.commit({ session, writelockAccounts: ["Income"] });
await delay(pause);
const result = await book.balance(
{
account: "Income",
},
{ session }
);
if (result.balance < 0) {
throw new Error("Not enough Balance in " + name + " transaction.");
}
}
await Promise.allSettled([
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
]);
const result = await book.balance({ account: "Income" });
expect(result.balance).to.be.equal(0);
});
it("should avoid double spending, commit() using writelockAccounts with a Regex", async function () {
const book = new Book("ACID" + Date.now());
await book.entry("depth test").credit("Income", 2).debit("Outcome", 2).commit();
async function spendOne(session: mongoose.ClientSession, name: string, pause: number) {
await book
.entry("depth test")
.credit("Savings", 1)
.debit("Income", 1)
.commit({ session, writelockAccounts: /Income/ });
await delay(pause);
const result = await book.balance(
{
account: "Income",
},
{ session }
);
if (result.balance < 0) {
throw new Error("Not enough Balance in " + name + " transaction.");
}
}
await Promise.allSettled([
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
]);
const result = await book.balance({ account: "Income" });
expect(result.balance).to.be.equal(0);
});
it("should avoid double spending, using book.writelockAccounts", async function () {
const book = new Book("ACID" + Date.now());
await book.entry("depth test").credit("Income", 2).debit("Outcome", 2).commit();
async function spendOne(session: mongoose.ClientSession, name: string, pause: number) {
await book.entry("depth test").credit("Savings", 1).debit("Income", 1).commit({ session });
await delay(pause);
const result = await book.balance(
{
account: "Income",
},
{ session }
);
if (result.balance < 0) {
throw new Error("Not enough Balance in " + name + " transaction.");
}
await book.writelockAccounts(["Income"], { session });
}
await Promise.allSettled([
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
]);
const result = await book.balance({ account: "Income" });
expect(result.balance).to.be.equal(0);
});
it("should create correct locks", async function () {
const book = new Book("ACID" + Date.now());
await lockModel.deleteMany({}).exec();
const beginDate = new Date();
await book.entry("depth test").credit("Income", 2).debit("Outcome", 2).commit();
async function spendOne(session: mongoose.ClientSession, name: string, pause: number) {
await book.entry("depth test").credit("Savings", 1).debit("Income", 1).commit({ session });
await delay(pause);
const result = await book.balance(
{
account: "Income",
},
{ session }
);
if (result.balance < 0) {
throw new Error("Not enough Balance in " + name + " transaction.");
}
await book.writelockAccounts(["Income"], { session });
}
await Promise.allSettled([
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
mongoose.connection.transaction(async (session) => {
await spendOne(session, "concurrent", 0);
}),
]);
const locks = await lockModel.find({}).lean().exec();
expect(locks).to.have.lengthOf(1);
expect(locks[0].book).to.be.equal(book.name);
expect(locks[0].account).to.be.equal("Income");
expect(locks[0].__v).to.be.equal(2);
expect(locks[0].updatedAt.getTime()).gt(beginDate.getTime());
expect(locks[0].updatedAt.getTime()).lt(Date.now());
});
});
}
================================================
FILE: src/Book.ts
================================================
import { Types } from "mongoose";
import {
JournalAlreadyVoidedError,
MediciError,
ConsistencyError,
JournalNotFoundError,
BookConstructorError,
} from "./errors";
import { handleVoidMemo } from "./helper/handleVoidMemo";
import { addReversedTransactions } from "./helper/addReversedTransactions";
import { IPaginationQuery, IFilterQuery, parseFilterQuery } from "./helper/parse/parseFilterQuery";
import { IBalanceQuery, parseBalanceQuery } from "./helper/parse/parseBalanceQuery";
import { Entry } from "./Entry";
import { IJournal, journalModel } from "./models/journal";
import { ITransaction, transactionModel } from "./models/transaction";
import type { IOptions } from "./IOptions";
import { lockModel } from "./models/lock";
import { getBestBalanceSnapshot, IBalance, snapshotBalance } from "./models/balance";
const GROUP = {
$group: {
_id: null,
balance: { $sum: { $subtract: ["$credit", "$debit"] } },
notes: { $sum: 1 },
lastTransactionId: { $max: "$_id" },
},
};
export class Book<U extends ITransaction = ITransaction, J extends IJournal = IJournal> {
name: string;
precision: number;
maxAccountPath: number;
balanceSnapshotSec: number;
expireBalanceSnapshotSec: number;
constructor(
name: string,
options = {} as {
precision?: number;
maxAccountPath?: number;
balanceSnapshotSec?: number;
expireBalanceSnapshotSec?: number;
}
) {
this.name = name;
this.precision = options.precision != null ? options.precision : 8;
this.maxAccountPath = options.maxAccountPath != null ? options.maxAccountPath : 3;
this.balanceSnapshotSec = options.balanceSnapshotSec != null ? options.balanceSnapshotSec : 24 * 60 * 60;
this.expireBalanceSnapshotSec =
options.expireBalanceSnapshotSec != null ? options.expireBalanceSnapshotSec : 2 * this.balanceSnapshotSec;
if (typeof this.name !== "string" || this.name.trim().length === 0) {
throw new BookConstructorError("Invalid value for name provided.");
}
if (typeof this.precision !== "number" || !Number.isInteger(this.precision) || this.precision < 0) {
throw new BookConstructorError("Invalid value for precision provided.");
}
if (typeof this.maxAccountPath !== "number" || !Number.isInteger(this.maxAccountPath) || this.maxAccountPath < 0) {
throw new BookConstructorError("Invalid value for maxAccountPath provided.");
}
if (typeof this.balanceSnapshotSec !== "number" || this.balanceSnapshotSec < 0) {
throw new BookConstructorError("Invalid value for balanceSnapshotSec provided.");
}
if (typeof this.expireBalanceSnapshotSec !== "number" || this.expireBalanceSnapshotSec < 0) {
throw new BookConstructorError("Invalid value for expireBalanceSnapshotSec provided.");
}
}
entry(memo: string, date = null as Date | null, original_journal?: string | Types.ObjectId): Entry<U, J> {
return Entry.write<U, J>(this, memo, date, original_journal);
}
async balance(query: IBalanceQuery, options = {} as IOptions): Promise<{ balance: number; notes: number }> {
// If there is a session, we must NOT set any readPreference (as per mongo v5 and v6).
// https://www.mongodb.com/docs/v6.0/core/transactions/#read-concern-write-concern-read-preference
// Otherwise, we are free to use any readPreference.
if (options && !options.session && !options.readPreference) {
// Let's try reading from the secondary node, if available.
options.readPreference = "secondaryPreferred";
}
const parsedQuery = parseBalanceQuery(query, this);
const meta = parsedQuery.meta;
delete parsedQuery.meta;
let balanceSnapshot: IBalance | null = null;
let accountForBalanceSnapshot: string | undefined;
if (this.balanceSnapshotSec) {
accountForBalanceSnapshot = query.account ? [].concat(query.account as never).join() : undefined;
balanceSnapshot = await getBestBalanceSnapshot(
{
book: parsedQuery.book,
account: accountForBalanceSnapshot,
meta,
},
options
);
if (balanceSnapshot) {
// Use cached balance
parsedQuery._id = { $gt: balanceSnapshot.transaction };
}
}
const match = { $match: parsedQuery };
const partialBalanceOptions = { ...options };
// If using a balance snapshot then make sure to use the appropriate (default "_id_") index for the additional balance calc.
if (parsedQuery._id && balanceSnapshot) {
const lastTransactionDate = balanceSnapshot.transaction.getTimestamp();
if (lastTransactionDate.getTime() + this.expireBalanceSnapshotSec * 1000 > Date.now()) {
// last transaction for this balance was just recently, then let's use the "_id" index as it will likely be faster than any other.
partialBalanceOptions.hint = { _id: 1 };
}
}
const result = (await transactionModel.collection.aggregate([match, GROUP], partialBalanceOptions).toArray())[0];
let balance = 0;
let notes = 0;
if (balanceSnapshot) {
balance += balanceSnapshot.balance;
notes += balanceSnapshot.notes;
}
if (result) {
balance += parseFloat(result.balance.toFixed(this.precision));
notes += result.notes;
// We can do snapshots only if there is at least one entry for this balance
if (this.balanceSnapshotSec && result.lastTransactionId) {
// It's the first (ever?) snapshot for this balance. We just need to save whatever we've just aggregated
// so that the very next balance query would use cached snapshot.
if (!balanceSnapshot) {
await snapshotBalance(
{
book: this.name,
account: accountForBalanceSnapshot,
meta,
transaction: result.lastTransactionId,
balance,
notes,
expireInSec: this.expireBalanceSnapshotSec,
} as IBalance & { expireInSec: number },
options
);
} else {
// There is a snapshot already. But let's check if it's too old.
const isSnapshotObsolete = Date.now() > balanceSnapshot.createdAt.getTime() + this.balanceSnapshotSec * 1000;
// If it's too old we would need to cache another snapshot.
if (isSnapshotObsolete) {
delete parsedQuery._id;
const match = { $match: parsedQuery };
// Important! We are going to recalculate the entire balance from the day one.
// Since this operation can take seconds (if you have millions of documents)
// we better run this query IN THE BACKGROUND.
// If this exact balance query would be executed multiple times at the same second we might end up with
// multiple snapshots in the database. Which is fine. The chance of this happening is low.
// Our main goal here is not to delay this .balance() method call. The tradeoff is that
// database will use 100% CPU for few (milli)seconds, which is fine. It's all fine (C)
transactionModel.collection
.aggregate([match, GROUP], options)
.toArray()
.then((results) => {
const resultFull = results[0];
return snapshotBalance(
{
book: this.name,
account: accountForBalanceSnapshot,
meta,
transaction: resultFull.lastTransactionId,
balance: parseFloat(resultFull.balance.toFixed(this.precision)),
notes: resultFull.notes,
expireInSec: this.expireBalanceSnapshotSec,
} as IBalance & { expireInSec: number },
options
);
})
.catch((error) => {
console.error("medici: Couldn't do background balance snapshot.", error);
});
}
}
}
}
return { balance, notes };
}
async ledger<T = U>(
query: IFilterQuery & IPaginationQuery,
options = {} as IOptions
): Promise<{ results: T[]; total: number }> {
// Pagination
const { perPage, page, ...restOfQuery } = query;
const paginationOptions: { skip?: number; limit?: number } = {};
if (typeof perPage === "number" && Number.isSafeInteger(perPage)) {
paginationOptions.skip = (Number.isSafeInteger(page) ? (page as number) - 1 : 0) * perPage;
paginationOptions.limit = perPage;
}
const filterQuery = parseFilterQuery(restOfQuery, this);
const findPromise = transactionModel.collection
.find(filterQuery, {
...paginationOptions,
sort: {
datetime: -1,
timestamp: -1,
},
session: options.session,
readPreference: options.readPreference,
readConcern: options.readConcern,
})
.toArray();
let countPromise = Promise.resolve(0);
if (paginationOptions.limit) {
countPromise = transactionModel.collection.countDocuments(filterQuery, {
session: options.session,
readPreference: options.readPreference,
readConcern: options.readConcern,
});
}
const results = (await findPromise) as T[];
return {
results,
total: (await countPromise) || results.length,
};
}
async void(
journal_id: string | Types.ObjectId,
reason?: undefined | string,
options = {} as IOptions,
use_original_date = false
) {
journal_id = typeof journal_id === "string" ? new Types.ObjectId(journal_id) : journal_id;
const journal = await journalModel.collection.findOne(
{
_id: journal_id,
book: this.name,
},
{
session: options.session,
readPreference: options.readPreference,
readConcern: options.readConcern,
projection: {
_id: true,
_transactions: true,
memo: true,
void_reason: true,
voided: true,
datetime: true,
},
}
);
if (journal === null) {
throw new JournalNotFoundError();
}
if (journal.voided) {
throw new JournalAlreadyVoidedError();
}
reason = handleVoidMemo(reason, journal.memo);
// Not using options.session here as this read operation is not necessary to be in the ACID session.
const transactions = await transactionModel.collection
.find(
{ _journal: journal._id },
{
session: options.session,
readPreference: options.readPreference,
readConcern: options.readConcern,
}
)
.toArray();
if (transactions.length !== journal._transactions.length) {
throw new MediciError(`Transactions for journal ${journal._id} not found on book ${journal.book}`);
}
const entry = this.entry(reason, use_original_date ? journal.datetime : null, journal_id);
addReversedTransactions(entry, transactions as ITransaction[]);
// Set this journal to void with reason and also set all associated transactions
const resultOne = await journalModel.collection.updateOne(
{ _id: journal._id },
{ $set: { voided: true, void_reason: reason } },
{
session: options.session, // We must provide either session or writeConcern, but not both.
writeConcern: options.session ? undefined : { w: 1, j: true }, // Ensure at least ONE node wrote to JOURNAL (disk)
}
);
// This can happen if someone read a journal, then deleted it from DB, then tried voiding. Full stop.
if (resultOne.matchedCount === 0)
throw new ConsistencyError(`Failed to void ${journal.memo} ${journal._id} journal on book ${journal.book}`);
// Someone else voided! Is it two simultaneous voidings? Let's stop our void action altogether.
if (resultOne.modifiedCount === 0)
throw new ConsistencyError(`Already voided ${journal.memo} ${journal._id} journal on book ${journal.book}`);
const resultMany = await transactionModel.collection.updateMany(
{ _journal: journal._id },
{ $set: { voided: true, void_reason: reason } },
{
session: options.session, // We must provide either session or writeConcern, but not both.
writeConcern: options.session ? undefined : { w: 1, j: true }, // Ensure at least ONE node wrote to JOURNAL (disk)
}
);
// At this stage we have to make sure the `commit()` is executed.
// Let's not make the DB even more inconsistent if something wild happens. Let's not throw, instead log to stderr.
if (resultMany.matchedCount !== transactions.length)
throw new ConsistencyError(
`Failed to void all ${journal.memo} ${journal._id} journal transactions on book ${journal.book}`
);
if (resultMany.modifiedCount === 0)
throw new ConsistencyError(
`Already voided ${journal.memo} ${journal._id} journal transactions on book ${journal.book}`
);
return entry.commit(options);
}
async writelockAccounts(accounts: string[], options: Required<Pick<IOptions, "session">>): Promise<Book<U, J>> {
accounts = Array.from(new Set(accounts));
// ISBN: 978-1-4842-6879-7. MongoDB Performance Tuning (2021), p. 217
// Reduce the Chance of Transient Transaction Errors by moving the
// contentious statement to the end of the transaction.
for (const account of accounts) {
await lockModel.collection.updateOne(
{ account, book: this.name },
{
$set: { updatedAt: new Date() },
$setOnInsert: { book: this.name, account },
$inc: { __v: 1 },
},
{ upsert: true, session: options.session }
);
}
return this;
}
async listAccounts(options = {} as IOptions): Promise<string[]> {
const distinctResult = await transactionModel.collection.distinct(
"accounts",
{ book: this.name },
{
session: options.session,
readPreference: options.readPreference,
readConcern: options.readConcern,
}
);
const accountsSet: Set<string> = new Set();
for (const fullAccountName of distinctResult) {
const paths = fullAccountName.split(":");
let path = paths[0];
accountsSet.add(path);
for (let i = 1; i < paths.length; ++i) {
path += ":" + paths[i];
accountsSet.add(path);
}
}
return Array.from(accountsSet).sort();
}
}
export default Book;
================================================
FILE: src/Entry.ts
================================================
import { Types } from "mongoose";
import { TransactionError, InvalidAccountPathLengthError } from "./errors";
import type { Book } from "./Book";
import { isValidTransactionKey, ITransaction, transactionModel } from "./models/transaction";
import { IJournal, journalModel, TJournalDocument } from "./models/journal";
import { isPrototypeAttribute } from "./helper/isPrototypeAttribute";
import type { IOptions } from "./IOptions";
import type { IAnyObject } from "./IAnyObject";
import { parseDateField } from "./helper/parse/parseDateField";
export class Entry<U extends ITransaction = ITransaction, J extends IJournal = IJournal> {
book: Book;
journal: TJournalDocument<J> & { _original_journal?: Types.ObjectId };
transactions: U[] = [];
timestamp = new Date();
static write<U extends ITransaction, J extends IJournal>(
book: Book,
memo: string,
date: Date | null,
original_journal?: string | Types.ObjectId
): Entry<U, J> {
return new this(book, memo, date, original_journal);
}
constructor(book: Book, memo: string, date: Date | null, original_journal?: string | Types.ObjectId) {
this.book = book;
this.journal = new journalModel() as TJournalDocument<J> & {
_original_journal?: Types.ObjectId;
};
this.journal.memo = String(memo);
if (original_journal) {
this.journal._original_journal =
typeof original_journal === "string" ? new Types.ObjectId(original_journal) : original_journal;
}
this.journal.datetime = parseDateField(date) || new Date();
this.journal.book = this.book.name;
this.transactions = [];
}
private transact(
type: -1 | 1,
account_path: string | string[],
amount: number | string,
extra: (Partial<U> & IAnyObject) | null
): Entry<U, J> {
if (typeof account_path === "string") {
account_path = account_path.split(":");
}
if (account_path.length > this.book.maxAccountPath) {
throw new InvalidAccountPathLengthError(`Account path is too deep (maximum ${this.book.maxAccountPath})`);
}
amount = typeof amount === "string" ? parseFloat(amount) : amount;
const credit = type === 1 ? amount : 0.0;
const debit = type === -1 ? amount : 0.0;
const transaction: ITransaction = {
// _id: keys are generated on the database side for better consistency
_journal: this.journal._id,
account_path,
accounts: account_path.join(":"),
book: this.book.name,
credit,
datetime: this.journal.datetime,
debit,
memo: this.journal.memo,
timestamp: this.timestamp,
};
if (this.journal._original_journal) {
transaction._original_journal = this.journal._original_journal;
}
if (extra) {
for (const [key, value] of Object.entries(extra)) {
if (isPrototypeAttribute(key)) continue;
if (isValidTransactionKey(key)) {
transaction[key] = value as never;
} else {
if (!transaction.meta) transaction.meta = {};
transaction.meta[key] = value;
}
}
}
// We set again timestamp to ensure there is no tampering with the timestamp
transaction.timestamp = this.timestamp;
this.transactions.push(transaction as U);
return this;
}
credit<T extends IAnyObject = IAnyObject>(
account_path: string | string[],
amount: number | string,
extra = null as (T & Partial<U>) | null
): Entry<U, J> {
return this.transact(1, account_path, amount, extra);
}
debit<T extends IAnyObject = IAnyObject>(
account_path: string | string[],
amount: number | string,
extra = null as (T & Partial<U>) | null
): Entry<U, J> {
return this.transact(-1, account_path, amount, extra);
}
async commit(options = {} as IOptions & { writelockAccounts?: string[] | RegExp }): Promise<Entry<U, J>["journal"]> {
let total = 0.0;
for (const tx of this.transactions) {
// sum the value of the transaction
total += tx.credit - tx.debit;
}
total = parseFloat(total.toFixed(this.book.precision));
if (total !== 0) {
throw new TransactionError("INVALID_JOURNAL: can't commit non zero total", total);
}
try {
await Promise.all(this.transactions.map((tx) => new transactionModel(tx).validate()));
await this.journal.validate();
const result = await transactionModel.collection.insertMany(this.transactions, {
forceServerObjectId: true, // This improves ordering of the entries on high load.
ordered: true, // Ensure items are inserted in the order provided.
session: options.session, // We must provide either session or writeConcern, but not both.
writeConcern: options.session ? undefined : { w: 1, j: true }, // Ensure at least ONE node wrote to JOURNAL (disk)
});
let insertedIds = Object.values(result.insertedIds) as Types.ObjectId[];
if (insertedIds.length !== this.transactions.length) {
throw new TransactionError(
`Saved only ${insertedIds.length} of ${this.transactions.length} transactions`,
total
);
}
if (!insertedIds[0]) {
// Mongo returns `undefined` as the insertedIds when forceServerObjectId=true. Let's re-read it.
const txs = await transactionModel.collection
.find(
{ _journal: this.transactions[0]._journal },
{
projection: { _id: 1 },
session: options.session,
readPreference: options.readPreference,
readConcern: options.readConcern,
}
)
.toArray();
insertedIds = txs.map((tx) => tx._id as Types.ObjectId);
}
this.journal._transactions = insertedIds as Types.ObjectId[];
await journalModel.collection.insertOne(this.journal.toObject(), options);
if (options.writelockAccounts && options.session) {
const writelockAccounts =
options.writelockAccounts instanceof RegExp
? this.transactions
.filter((tx) => (options.writelockAccounts as RegExp).test(tx.accounts))
.map((tx) => tx.accounts)
: options.writelockAccounts;
await this.book.writelockAccounts(writelockAccounts, {
session: options.session,
});
}
return this.journal;
} catch (err) {
if (!options.session) {
throw new TransactionError(`Failure to save journal: ${(err as Error).message}`, total);
}
throw err;
}
}
}
export default Entry;
================================================
FILE: src/IAnyObject.ts
================================================
/* eslint @typescript-eslint/no-explicit-any: off */
export interface IAnyObject {
[k: string]: any;
}
================================================
FILE: src/IOptions.ts
================================================
import type { ClientSession } from "mongoose";
import type { ReadPreferenceLike, Hint, ReadConcernLike } from "mongodb";
// aggregate of mongoose expects Record<string, unknown> type
export type IOptions = {
session?: ClientSession;
readPreference?: ReadPreferenceLike;
hint?: Hint;
readConcern?: ReadConcernLike;
};
================================================
FILE: src/errors/BookConstructorError.ts
================================================
import { MediciError } from "./MediciError";
export class BookConstructorError extends MediciError {
public code = 400;
constructor(message: string, code = 400) {
super(message);
this.code = code;
}
}
================================================
FILE: src/errors/ConsistencyError.ts
================================================
import { MediciError } from "./MediciError";
export class ConsistencyError extends MediciError {
public code = 400;
constructor(message = "medici ledge consistency harmed", code = 400) {
super(message);
this.code = code;
}
}
================================================
FILE: src/errors/InvalidAccountPathLengthError.ts
================================================
import { MediciError } from "./MediciError";
export class InvalidAccountPathLengthError extends MediciError {
public code = 400;
constructor(message: string, code = 400) {
super(message);
this.code = code;
}
}
================================================
FILE: src/errors/JournalAlreadyVoidedError.ts
================================================
import { MediciError } from "./MediciError";
export class JournalAlreadyVoidedError extends MediciError {
public code = 400;
constructor(message = "Journal already voided.", code = 400) {
super(message);
this.code = code;
}
}
================================================
FILE: src/errors/JournalNotFoundError.ts
================================================
import { MediciError } from "./MediciError";
export class JournalNotFoundError extends MediciError {
public code = 404;
constructor(message = "Journal could not be found.", code = 403) {
super(message);
this.code = code;
}
}
================================================
FILE: src/errors/MediciError.ts
================================================
export class MediciError extends Error {
public code = 500;
constructor(message: string, code = 500) {
super(message);
this.code = code;
}
}
================================================
FILE: src/errors/TransactionError.ts
================================================
import { MediciError } from "./MediciError";
export class TransactionError extends MediciError {
public code = 400;
public total: number;
constructor(message: string, total: number, code = 400) {
super(message);
this.total = total;
this.code = code;
}
}
================================================
FILE: src/errors/index.ts
================================================
export { MediciError } from "./MediciError";
export { BookConstructorError } from "./BookConstructorError";
export { ConsistencyError } from "./ConsistencyError";
export { InvalidAccountPathLengthError } from "./InvalidAccountPathLengthError";
export { JournalAlreadyVoidedError } from "./JournalAlreadyVoidedError";
export { JournalNotFoundError } from "./JournalNotFoundError";
export { TransactionError } from "./TransactionError";
================================================
FILE: src/helper/addReversedTransactions.ts
================================================
import { Entry } from "..";
import { IAnyObject } from "../IAnyObject";
import { ITransaction } from "../models/transaction";
import { safeSetKeyToMetaObject } from "./safeSetKeyToMetaObject";
export function addReversedTransactions<T extends ITransaction = ITransaction>(entry: Entry, transactions: T[]) {
for (const transaction of transactions) {
const newMeta: IAnyObject = {};
for (const [key, value] of Object.entries(transaction)) {
if (key === "meta") {
for (const [keyMeta, valueMeta] of Object.entries(value)) {
safeSetKeyToMetaObject(keyMeta, valueMeta, newMeta);
}
} else {
safeSetKeyToMetaObject(key, value, newMeta);
}
}
if (transaction.credit) {
entry.debit(transaction.account_path, transaction.credit, newMeta);
}
if (transaction.debit) {
entry.credit(transaction.account_path, transaction.debit, newMeta);
}
}
}
================================================
FILE: src/helper/extractObjectIdKeysFromSchema.ts
================================================
import { Schema } from "mongoose";
export function extractObjectIdKeysFromSchema(schema: Schema) {
const result: Set<string> = new Set();
for (const [key, value] of Object.entries(schema.paths)) {
if (value instanceof Schema.Types.ObjectId) {
result.add(key);
}
}
return result;
}
================================================
FILE: src/helper/flattenObject.ts
================================================
import { IAnyObject } from "../IAnyObject";
export function flattenObject(obj?: IAnyObject, parent?: string, deep = false, res: IAnyObject = {}) {
if (!obj) return {};
for (const [key, value] of Object.entries(obj)) {
const propName = parent ? parent + "." + key : key;
if (deep && typeof obj[key] === "object") {
flattenObject(value, propName, deep, res);
} else {
res[propName] = value;
}
}
return res;
}
================================================
FILE: src/helper/handleVoidMemo.ts
================================================
const voidRE = /^\[VOID\]/;
const unvoidRE = /^\[UNVOID\]/;
const revoidRE = /^\[REVOID\]/;
export function handleVoidMemo(reason: string | undefined | null, memo: string | undefined | null): string {
if (reason) {
return reason;
} else if (!memo) {
return "[VOID]";
} else if (voidRE.test(memo)) {
return memo.replace("[VOID]", "[UNVOID]");
} else if (unvoidRE.test(memo)) {
return memo.replace("[UNVOID]", "[REVOID]");
} else if (revoidRE.test(memo)) {
return memo.replace("[REVOID]", "[UNVOID]");
} else {
return `[VOID] ${memo}`;
}
}
================================================
FILE: src/helper/initModels.ts
================================================
import { journalModel } from "../models/journal";
import { transactionModel } from "../models/transaction";
import { lockModel } from "../models/lock";
import { balanceModel } from "../models/balance";
export async function initModels() {
await journalModel.init();
await transactionModel.init();
await lockModel.init();
await balanceModel.init();
}
================================================
FILE: src/helper/isPrototypeAttribute.ts
================================================
const reservedWords: Set<string> = new Set([
"__proto__",
"__defineGetter__",
"__lookupGetter__",
"__defineSetter__",
"__lookupSetter__",
"constructor",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"toString",
"toLocaleString",
"valueOf",
]);
/**
* Check if a key is a reserved word to avoid any prototype-pollution.
*/
export function isPrototypeAttribute(value: string): boolean {
return reservedWords.has(value);
}
================================================
FILE: src/helper/mongoTransaction.ts
================================================
/* eslint require-await: off */
import { ClientSession, connection } from "mongoose";
import { IAnyObject } from "../IAnyObject";
export async function mongoTransaction<T = unknown>(fn: (session: ClientSession) => Promise<T>, options?: IAnyObject) {
return connection.transaction(fn, options);
}
================================================
FILE: src/helper/parse/IFilter.ts
================================================
import { Collection } from "mongoose";
export type IFilter = Parameters<Collection["find"]>[0];
================================================
FILE: src/helper/parse/parseAccountField.ts
================================================
import type { IFilter } from "./IFilter";
export function parseAccountField(account: string | string[] | undefined, maxAccountPath = 3): IFilter {
const filterQuery: IFilter = {};
if (typeof account === "string") {
const splitAccount = account.split(":");
if (splitAccount.length === maxAccountPath) {
filterQuery.accounts = account;
} else {
for (let i = 0; i < splitAccount.length; i++) {
filterQuery[`account_path.${i}`] = splitAccount[i];
}
}
} else if (Array.isArray(account)) {
if (account.length === 1) {
return parseAccountField(account[0], maxAccountPath);
} else {
filterQuery["$or"] = new Array(account.length);
for (let i = 0; i < account.length; i++) {
filterQuery["$or"][i] = parseAccountField(account[i], maxAccountPath);
}
}
}
return filterQuery;
}
================================================
FILE: src/helper/parse/parseBalanceQuery.ts
================================================
import { Types } from "mongoose";
import type { Book } from "../../Book";
import { isPrototypeAttribute } from "../isPrototypeAttribute";
import { parseAccountField } from "./parseAccountField";
import { parseDateQuery } from "./parseDateField";
import type { IFilter } from "./IFilter";
import { IAnyObject } from "../../IAnyObject";
import { isTransactionObjectIdKey, isValidTransactionKey } from "../../models/transaction";
import { flattenObject } from "../flattenObject";
export type IBalanceQuery = {
account?: string | string[];
start_date?: Date | string | number;
end_date?: Date | string | number;
} & {
[key: string]: string[] | number | string | Date | boolean | Types.ObjectId | IAnyObject;
};
/**
* Turn query into an object readable by MongoDB.
*/
export function parseBalanceQuery(
query: IBalanceQuery,
book: Pick<Book, "name"> & Partial<Pick<Book, "maxAccountPath">>
): IFilter {
const { account, start_date, end_date, ...extra } = query;
const filterQuery: IFilter = {
book: book.name,
...parseAccountField(account, book.maxAccountPath),
};
if (start_date || end_date) {
filterQuery["datetime"] = parseDateQuery(start_date, end_date);
}
const meta: IAnyObject = {};
for (const [key, value] of Object.entries(extra)) {
if (isPrototypeAttribute(key)) continue;
if (!filterQuery.meta) filterQuery.meta = {};
filterQuery.meta[key] = value;
let newValue = value;
if (typeof value === "string" && isTransactionObjectIdKey(key)) {
newValue = new Types.ObjectId(value);
}
if (isValidTransactionKey(key)) {
filterQuery[key] = newValue;
} else {
meta[key] = newValue;
}
}
return { ...filterQuery, ...flattenObject(meta, "meta") };
}
================================================
FILE: src/helper/parse/parseDateField.ts
================================================
const numberRE = /^\d+$/;
export function parseDateField(value: unknown): Date | undefined {
if (value instanceof Date) {
return value;
} else if (typeof value === "number") {
return new Date(value);
} else if (typeof value === "string" && numberRE.test(value)) {
return new Date(parseInt(value));
} else if (typeof value === "string") {
return new Date(value);
}
// Using JS type auto conversion. This code can lose milliseconds. By design.
// Consider throwing exception in the next breaking release.
const parsed = Date.parse(value as string);
if (parsed) return new Date(parsed);
}
export type IDateFilter = {
$gte?: Date;
$lte?: Date;
};
export function parseDateQuery(start_date: unknown, end_date: unknown): IDateFilter {
const datetime: IDateFilter = {};
if (start_date) {
datetime.$gte = parseDateField(start_date);
}
if (end_date) {
datetime.$lte = parseDateField(end_date);
}
return datetime;
}
================================================
FILE: src/helper/parse/parseFilterQuery.ts
================================================
import { Types } from "mongoose";
import type { Book } from "../../Book";
import { isTransactionObjectIdKey, isValidTransactionKey, ITransaction } from "../../models/transaction";
import { isPrototypeAttribute } from "../isPrototypeAttribute";
import { parseAccountField } from "./parseAccountField";
import { parseDateQuery } from "./parseDateField";
import type { IFilter } from "./IFilter";
import { flattenObject } from "../flattenObject";
import { IAnyObject } from "../../IAnyObject";
export type IFilterQuery = {
account?: string | string[];
_journal?: Types.ObjectId | string;
start_date?: Date | string | number;
end_date?: Date | string | number;
} & Partial<ITransaction> & {
[key: string]: string[] | number | string | Date | boolean | Types.ObjectId;
};
export interface IPaginationQuery {
perPage?: number;
page?: number;
}
/**
* Turn query into an object readable by MongoDB.
*/
export function parseFilterQuery(
query: IFilterQuery & IPaginationQuery,
book: Pick<Book, "name"> & Partial<Pick<Book, "maxAccountPath">>
): IFilter {
const { account, start_date, end_date, ...extra } = query;
const filterQuery: IFilter = {
book: book.name,
...parseAccountField(account, book.maxAccountPath),
};
if (start_date || end_date) {
filterQuery["datetime"] = parseDateQuery(start_date, end_date);
}
const meta: IAnyObject = {};
for (const [key, value] of Object.entries(extra)) {
if (isPrototypeAttribute(key)) continue;
let newValue = value;
if (typeof value === "string" && isTransactionObjectIdKey(key)) {
newValue = new Types.ObjectId(value);
}
if (isValidTransactionKey(key)) {
filterQuery[key] = newValue;
} else {
meta[key] = newValue;
}
}
return { ...filterQuery, ...flattenObject(meta, "meta") };
}
================================================
FILE: src/helper/safeSetKeyToMetaObject.ts
================================================
import { isValidTransactionKey, defaultTransactionSchemaKeys } from "../models/transaction";
import { isPrototypeAttribute } from "./isPrototypeAttribute";
import type { IAnyObject } from "../IAnyObject";
export function safeSetKeyToMetaObject(key: string, val: unknown, meta: IAnyObject): void {
if (isPrototypeAttribute(key)) return;
if (!isValidTransactionKey(key, defaultTransactionSchemaKeys)) meta[key] = val;
}
================================================
FILE: src/helper/syncIndexes.ts
================================================
import { journalModel } from "../models/journal";
import { lockModel } from "../models/lock";
import { transactionModel } from "../models/transaction";
import { balanceModel } from "../models/balance";
/**
* Will execute mongoose model's `syncIndexes()` for all medici models.
* WARNING! This will erase any custom (non-builtin) indexes you might have added.
* @param [options] {{background: Boolean}}
*/
export async function syncIndexes(options?: { background: boolean }) {
await journalModel.syncIndexes(options);
await transactionModel.syncIndexes(options);
await lockModel.syncIndexes(options);
await balanceModel.syncIndexes(options);
}
================================================
FILE: src/index.ts
================================================
import { Book } from "./Book";
import type { Entry } from "./Entry";
export { setJournalSchema } from "./models/journal";
export { setTransactionSchema } from "./models/transaction";
export { setLockSchema } from "./models/lock";
export { mongoTransaction } from "./helper/mongoTransaction";
export { initModels } from "./helper/initModels";
export { syncIndexes } from "./helper/syncIndexes";
export { MediciError } from "./errors/MediciError";
export { BookConstructorError } from "./errors/BookConstructorError";
export { InvalidAccountPathLengthError } from "./errors/InvalidAccountPathLengthError";
export { JournalAlreadyVoidedError } from "./errors/JournalAlreadyVoidedError";
export { JournalNotFoundError } from "./errors/JournalNotFoundError";
export { TransactionError } from "./errors/TransactionError";
export { Book, Entry };
export default Book;
================================================
FILE: src/models/balance.ts
================================================
import { createHash } from "crypto";
import { Schema, model, Model, connection, Types, FilterQuery } from "mongoose";
import type { IAnyObject } from "../IAnyObject";
import type { IOptions } from "../IOptions";
import { flattenObject } from "../helper/flattenObject";
export interface IBalance {
_id: Types.ObjectId;
key: string;
rawKey: string;
book: string;
account?: string;
transaction: Types.ObjectId;
meta: IAnyObject;
balance: number;
notes: number;
createdAt: Date;
expireAt: Date;
}
const balanceSchema = new Schema<IBalance>(
{
key: String,
rawKey: String,
book: String,
account: String,
transaction: Types.ObjectId,
meta: Schema.Types.Mixed,
balance: Number,
notes: Number,
createdAt: Date,
expireAt: Date,
},
{ id: false, versionKey: false, timestamps: false }
);
balanceSchema.index({ key: 1 });
balanceSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 });
export let balanceModel: Model<IBalance>;
export function setBalanceSchema(schema: Schema, collection?: string) {
if (connection.models["Medici_Balance"]) {
connection.deleteModel("Medici_Balance");
}
balanceModel = model("Medici_Balance", schema, collection) as unknown as Model<IBalance>;
}
!connection.models["Medici_Balance"] && setBalanceSchema(balanceSchema);
export function hashKey(key: string) {
return createHash("sha1").update(key).digest().toString("latin1");
}
export function constructKey(book: string, account?: string, meta?: IAnyObject): string {
// Example of a simple key: "My book;Liabilities:12345"
// Example of a complex key: "My book;Liabilities:Client,Liabilities:Client Pending;clientId.$in.0:12345,clientId.$in.1:67890"
return [
book,
account,
Object.entries(flattenObject(meta, "", true))
.sort()
.map(([key, value]) => key + ":" + value)
.join(),
]
.filter(Boolean)
.join(";");
}
export async function snapshotBalance(
balanceData: IBalance & { expireInSec: number },
options: IOptions = {}
): Promise<boolean> {
const rawKey = constructKey(balanceData.book, balanceData.account, balanceData.meta);
const key = hashKey(rawKey);
const balanceDoc = {
key,
rawKey,
book: balanceData.book,
account: balanceData.account,
meta: JSON.stringify(balanceData.meta),
transaction: balanceData.transaction,
balance: balanceData.balance,
notes: balanceData.notes,
createdAt: new Date(),
expireAt: new Date(Date.now() + balanceData.expireInSec * 1000),
};
const result = await balanceModel.collection.insertOne(balanceDoc, {
session: options.session,
writeConcern: options.session ? undefined : { w: 1, j: true }, // Ensure at least ONE node wrote to JOURNAL (disk)
forceServerObjectId: true,
});
return result.acknowledged;
}
export function getBestBalanceSnapshot(query: FilterQuery<IBalance>, options: IOptions = {}): Promise<IBalance | null> {
const { book, account, meta, ...extras } = query;
const key = hashKey(constructKey(book, account, { ...meta, ...extras }));
return balanceModel.collection.findOne({ key }, { sort: { _id: -1 }, ...options }) as Promise<IBalance | null>;
}
================================================
FILE: src/models/journal.ts
================================================
import { connection, Schema, Document, Model, model, Types } from "mongoose";
export interface IJournal {
_id: Types.ObjectId;
datetime: Date;
memo: string;
_transactions: Types.ObjectId[];
book: string;
voided?: boolean;
void_reason?: string;
}
const journalSchema = new Schema<IJournal>(
{
datetime: Date,
memo: {
type: String,
default: "",
},
_transactions: [
{
type: Schema.Types.ObjectId,
ref: "Medici_Transaction",
},
],
book: String,
voided: Boolean,
void_reason: String,
},
{ id: false, versionKey: false, timestamps: false }
);
export type TJournalDocument<T extends IJournal = IJournal> = Omit<Document, "__v" | "id"> & T;
type TJournalModel<T extends IJournal = IJournal> = Model<T>;
export let journalModel: TJournalModel;
export function setJournalSchema(schema: Schema, collection?: string) {
if (connection.models["Medici_Journal"]) {
connection.deleteModel("Medici_Journal");
}
journalModel = model("Medici_Journal", schema, collection) as unknown as TJournalModel;
}
!connection.models["Medici_Journal"] && setJournalSchema(journalSchema);
================================================
FILE: src/models/lock.ts
================================================
import { Schema, model, Model, connection } from "mongoose";
export interface ILock {
account: string;
book: string;
updatedAt: Date;
__v: number;
}
const lockSchema = new Schema<ILock>(
{
book: String,
account: String,
updatedAt: Date,
__v: Number,
},
{ id: false, versionKey: false, timestamps: false }
);
lockSchema.index(
{
account: 1,
book: 1,
},
{ unique: true }
);
lockSchema.index(
{
updatedAt: 1,
},
{ expireAfterSeconds: 60 * 60 * 24 }
);
export let lockModel: Model<ILock>;
export function setLockSchema(schema: Schema, collection?: string) {
if (connection.models["Medici_Lock"]) {
connection.deleteModel("Medici_Lock");
}
lockModel = model("Medici_Lock", schema, collection) as unknown as Model<ILock>;
}
!connection.models["Medici_Lock"] && setLockSchema(lockSchema);
================================================
FILE: src/models/transaction.ts
================================================
import { connection, Schema, model, Model, Types } from "mongoose";
import { extractObjectIdKeysFromSchema } from "../helper/extractObjectIdKeysFromSchema";
import type { IAnyObject } from "../IAnyObject";
export interface ITransaction {
_id?: Types.ObjectId;
credit: number;
debit: number;
meta?: IAnyObject;
datetime: Date;
account_path: string[];
accounts: string;
book: string;
memo: string;
_journal: Types.ObjectId;
timestamp: Date;
voided?: boolean;
void_reason?: string;
_original_journal?: Types.ObjectId;
}
export const transactionSchema = new Schema<ITransaction>(
{
credit: Number,
debit: Number,
meta: Schema.Types.Mixed,
datetime: Date,
account_path: [String],
accounts: String,
book: String,
memo: String,
_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
timestamp: Date,
voided: Boolean,
void_reason: String,
// The journal that this is voiding, if any
_original_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
},
{ id: false, versionKey: false, timestamps: false }
);
export let transactionModel: Model<ITransaction>;
export const defaultTransactionSchemaKeys: Set<string> = new Set(Object.keys(transactionSchema.paths));
let transactionSchemaKeys: Set<string> = defaultTransactionSchemaKeys;
export function isValidTransactionKey<T extends ITransaction = ITransaction>(
value: unknown,
schemaKeys: Set<string> = transactionSchemaKeys
): value is keyof T {
return typeof value === "string" && schemaKeys.has(value);
}
let transactionSchemaObjectIdKeys: Set<string> = extractObjectIdKeysFromSchema(transactionSchema);
export function isTransactionObjectIdKey(value: unknown): boolean {
return typeof value === "string" && transactionSchemaObjectIdKeys.has(value);
}
export function setTransactionSchema(schema: Schema, collection?: string, options = {} as { defaultIndexes: boolean }) {
const { defaultIndexes = true } = options;
if (connection.models["Medici_Transaction"]) {
connection.deleteModel("Medici_Transaction");
}
if (defaultIndexes) {
schema.index({
_journal: 1,
});
schema.index({
book: 1,
accounts: 1,
datetime: -1,
});
schema.index({
book: 1,
"account_path.0": 1,
"account_path.1": 1,
"account_path.2": 1,
datetime: -1,
});
}
transactionModel = model("Medici_Transaction", schema, collection) as unknown as Model<ITransaction>;
transactionSchemaKeys = new Set(Object.keys(schema.paths));
transactionSchemaObjectIdKeys = extractObjectIdKeysFromSchema(schema);
}
!connection.models["Medici_Transaction"] && setTransactionSchema(transactionSchema);
================================================
FILE: tsconfig.eslint.json
================================================
{
"display": "Node 16",
"compilerOptions": {
"lib": ["es2021"],
"module": "commonjs",
"target": "es2021",
"outDir": "build",
"strict": true,
"skipLibCheck": true,
"sourceMap": false,
"forceConsistentCasingInFileNames": true
}
}
================================================
FILE: tsconfig.json
================================================
{
"display": "Node 16",
"compilerOptions": {
"lib": ["es2021"],
"module": "commonjs",
"target": "es2021",
"outDir": "build",
"strict": true,
"strictNullChecks": true,
"skipLibCheck": true,
"noImplicitThis": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"sourceMap": false,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
================================================
FILE: tsconfig.types.json
================================================
{
"display": "Typings",
"compilerOptions": {
"sourceMap": true,
"noImplicitAny": false,
"declaration": true,
"module": "commonjs",
"target": "es2021",
"emitDeclarationOnly": true,
"moduleResolution": "node",
"rootDir": "src",
"experimentalDecorators": true,
"resolveJsonModule": false,
// Explicitly set types settings so typescript doesn't auto-discover types.
// If all types are discovered then all types need to be included as deps
// or typescript may error out with TS2688: Cannot find type definition file for 'foo'.
"types": [
"node"
],
"skipLibCheck": true
},
"include": [
"src"
]
}
gitextract_7ssxit57/ ├── .editorconfig ├── .eslintrc.json ├── .github/ │ └── workflows/ │ ├── check-code.yml │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .nycrc ├── LICENSE ├── README.md ├── bench/ │ ├── bench-balance.ts │ └── bench-ledger.ts ├── package.json ├── spec/ │ ├── balance.spec.ts │ ├── book.spec.ts │ ├── constructKey.spec.ts │ ├── extractObjectIdKeysFromSchema.spec.ts │ ├── fpPrecision.spec.ts │ ├── handleVoidMemo.spec.ts │ ├── helper/ │ │ ├── MongoDB.spec.ts │ │ ├── delay.ts │ │ └── transactionSchema.ts │ ├── index.spec.ts │ ├── parseBalanceQuery.spec.ts │ ├── parseDateField.spec.ts │ ├── parseFilterQuery.spec.ts │ ├── safeSetKeyToMetaObject.spec.ts │ ├── setTransactionSchema.spec.ts │ ├── types/ │ │ └── medici.spec-d.ts │ └── xacid.spec.ts ├── src/ │ ├── Book.ts │ ├── Entry.ts │ ├── IAnyObject.ts │ ├── IOptions.ts │ ├── errors/ │ │ ├── BookConstructorError.ts │ │ ├── ConsistencyError.ts │ │ ├── InvalidAccountPathLengthError.ts │ │ ├── JournalAlreadyVoidedError.ts │ │ ├── JournalNotFoundError.ts │ │ ├── MediciError.ts │ │ ├── TransactionError.ts │ │ └── index.ts │ ├── helper/ │ │ ├── addReversedTransactions.ts │ │ ├── extractObjectIdKeysFromSchema.ts │ │ ├── flattenObject.ts │ │ ├── handleVoidMemo.ts │ │ ├── initModels.ts │ │ ├── isPrototypeAttribute.ts │ │ ├── mongoTransaction.ts │ │ ├── parse/ │ │ │ ├── IFilter.ts │ │ │ ├── parseAccountField.ts │ │ │ ├── parseBalanceQuery.ts │ │ │ ├── parseDateField.ts │ │ │ └── parseFilterQuery.ts │ │ ├── safeSetKeyToMetaObject.ts │ │ └── syncIndexes.ts │ ├── index.ts │ └── models/ │ ├── balance.ts │ ├── journal.ts │ ├── lock.ts │ └── transaction.ts ├── tsconfig.eslint.json ├── tsconfig.json └── tsconfig.types.json
SYMBOL INDEX (76 symbols across 33 files)
FILE: spec/book.spec.ts
function addBalance (line 269) | async function addBalance(book: Book) {
function addBalance (line 710) | async function addBalance(book: Book, suffix = "") {
FILE: spec/helper/transactionSchema.ts
type ITransactionTest (line 4) | interface ITransactionTest {
function getTransactionSchemaTest (line 25) | function getTransactionSchemaTest() {
FILE: spec/safeSetKeyToMetaObject.spec.ts
type ITransactionNew (line 8) | interface ITransactionNew {
FILE: spec/xacid.spec.ts
function spendOne (line 240) | async function spendOne(session: mongoose.ClientSession, name: string, p...
function spendOne (line 326) | async function spendOne(session: mongoose.ClientSession, name: string, p...
function spendOne (line 412) | async function spendOne(session: mongoose.ClientSession, name: string, p...
function spendOne (line 500) | async function spendOne(session: mongoose.ClientSession, name: string, p...
FILE: src/Book.ts
constant GROUP (line 20) | const GROUP = {
class Book (line 29) | class Book<U extends ITransaction = ITransaction, J extends IJournal = I...
method constructor (line 36) | constructor(
method entry (line 73) | entry(memo: string, date = null as Date | null, original_journal?: str...
method balance (line 77) | async balance(query: IBalanceQuery, options = {} as IOptions): Promise...
method ledger (line 194) | async ledger<T = U>(
method void (line 238) | async void(
method writelockAccounts (line 336) | async writelockAccounts(accounts: string[], options: Required<Pick<IOp...
method listAccounts (line 356) | async listAccounts(options = {} as IOptions): Promise<string[]> {
FILE: src/Entry.ts
class Entry (line 11) | class Entry<U extends ITransaction = ITransaction, J extends IJournal = ...
method write (line 17) | static write<U extends ITransaction, J extends IJournal>(
method constructor (line 26) | constructor(book: Book, memo: string, date: Date | null, original_jour...
method transact (line 43) | private transact(
method credit (line 96) | credit<T extends IAnyObject = IAnyObject>(
method debit (line 104) | debit<T extends IAnyObject = IAnyObject>(
method commit (line 112) | async commit(options = {} as IOptions & { writelockAccounts?: string[]...
FILE: src/IAnyObject.ts
type IAnyObject (line 2) | interface IAnyObject {
FILE: src/IOptions.ts
type IOptions (line 5) | type IOptions = {
FILE: src/errors/BookConstructorError.ts
class BookConstructorError (line 3) | class BookConstructorError extends MediciError {
method constructor (line 6) | constructor(message: string, code = 400) {
FILE: src/errors/ConsistencyError.ts
class ConsistencyError (line 3) | class ConsistencyError extends MediciError {
method constructor (line 6) | constructor(message = "medici ledge consistency harmed", code = 400) {
FILE: src/errors/InvalidAccountPathLengthError.ts
class InvalidAccountPathLengthError (line 3) | class InvalidAccountPathLengthError extends MediciError {
method constructor (line 6) | constructor(message: string, code = 400) {
FILE: src/errors/JournalAlreadyVoidedError.ts
class JournalAlreadyVoidedError (line 3) | class JournalAlreadyVoidedError extends MediciError {
method constructor (line 6) | constructor(message = "Journal already voided.", code = 400) {
FILE: src/errors/JournalNotFoundError.ts
class JournalNotFoundError (line 3) | class JournalNotFoundError extends MediciError {
method constructor (line 6) | constructor(message = "Journal could not be found.", code = 403) {
FILE: src/errors/MediciError.ts
class MediciError (line 1) | class MediciError extends Error {
method constructor (line 4) | constructor(message: string, code = 500) {
FILE: src/errors/TransactionError.ts
class TransactionError (line 3) | class TransactionError extends MediciError {
method constructor (line 7) | constructor(message: string, total: number, code = 400) {
FILE: src/helper/addReversedTransactions.ts
function addReversedTransactions (line 6) | function addReversedTransactions<T extends ITransaction = ITransaction>(...
FILE: src/helper/extractObjectIdKeysFromSchema.ts
function extractObjectIdKeysFromSchema (line 3) | function extractObjectIdKeysFromSchema(schema: Schema) {
FILE: src/helper/flattenObject.ts
function flattenObject (line 3) | function flattenObject(obj?: IAnyObject, parent?: string, deep = false, ...
FILE: src/helper/handleVoidMemo.ts
function handleVoidMemo (line 5) | function handleVoidMemo(reason: string | undefined | null, memo: string ...
FILE: src/helper/initModels.ts
function initModels (line 6) | async function initModels() {
FILE: src/helper/isPrototypeAttribute.ts
function isPrototypeAttribute (line 19) | function isPrototypeAttribute(value: string): boolean {
FILE: src/helper/mongoTransaction.ts
function mongoTransaction (line 5) | async function mongoTransaction<T = unknown>(fn: (session: ClientSession...
FILE: src/helper/parse/IFilter.ts
type IFilter (line 3) | type IFilter = Parameters<Collection["find"]>[0];
FILE: src/helper/parse/parseAccountField.ts
function parseAccountField (line 3) | function parseAccountField(account: string | string[] | undefined, maxAc...
FILE: src/helper/parse/parseBalanceQuery.ts
type IBalanceQuery (line 11) | type IBalanceQuery = {
function parseBalanceQuery (line 22) | function parseBalanceQuery(
FILE: src/helper/parse/parseDateField.ts
function parseDateField (line 3) | function parseDateField(value: unknown): Date | undefined {
type IDateFilter (line 20) | type IDateFilter = {
function parseDateQuery (line 25) | function parseDateQuery(start_date: unknown, end_date: unknown): IDateFi...
FILE: src/helper/parse/parseFilterQuery.ts
type IFilterQuery (line 11) | type IFilterQuery = {
type IPaginationQuery (line 20) | interface IPaginationQuery {
function parseFilterQuery (line 28) | function parseFilterQuery(
FILE: src/helper/safeSetKeyToMetaObject.ts
function safeSetKeyToMetaObject (line 5) | function safeSetKeyToMetaObject(key: string, val: unknown, meta: IAnyObj...
FILE: src/helper/syncIndexes.ts
function syncIndexes (line 11) | async function syncIndexes(options?: { background: boolean }) {
FILE: src/models/balance.ts
type IBalance (line 7) | interface IBalance {
function setBalanceSchema (line 43) | function setBalanceSchema(schema: Schema, collection?: string) {
function hashKey (line 53) | function hashKey(key: string) {
function constructKey (line 57) | function constructKey(book: string, account?: string, meta?: IAnyObject)...
function snapshotBalance (line 73) | async function snapshotBalance(
function getBestBalanceSnapshot (line 100) | function getBestBalanceSnapshot(query: FilterQuery<IBalance>, options: I...
FILE: src/models/journal.ts
type IJournal (line 3) | interface IJournal {
type TJournalDocument (line 33) | type TJournalDocument<T extends IJournal = IJournal> = Omit<Document, "_...
type TJournalModel (line 35) | type TJournalModel<T extends IJournal = IJournal> = Model<T>;
function setJournalSchema (line 39) | function setJournalSchema(schema: Schema, collection?: string) {
FILE: src/models/lock.ts
type ILock (line 3) | interface ILock {
function setLockSchema (line 36) | function setLockSchema(schema: Schema, collection?: string) {
FILE: src/models/transaction.ts
type ITransaction (line 5) | interface ITransaction {
function isValidTransactionKey (line 54) | function isValidTransactionKey<T extends ITransaction = ITransaction>(
function isTransactionObjectIdKey (line 63) | function isTransactionObjectIdKey(value: unknown): boolean {
function setTransactionSchema (line 67) | function setTransactionSchema(schema: Schema, collection?: string, optio...
Condensed preview — 63 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (189K chars).
[
{
"path": ".editorconfig",
"chars": 447,
"preview": "# EditorConfig helps developers define and maintain consistent\n# coding styles between different editors and IDEs\n# edit"
},
{
"path": ".eslintrc.json",
"chars": 805,
"preview": "{\n \"root\": true,\n \"parser\": \"@typescript-eslint/parser\",\n \"parserOptions\": {\n \"project\": \"./tsconfig.eslint.json\"\n"
},
{
"path": ".github/workflows/check-code.yml",
"chars": 325,
"preview": "name: \"Check code\"\non:\n pull_request:\n branches: [ master ]\njobs:\n check-code:\n name: Check Code\n runs-on: ub"
},
{
"path": ".github/workflows/ci.yml",
"chars": 975,
"preview": "name: CI\non: [push, pull_request]\njobs:\n test:\n name: Node ${{ matrix.node }}, Mongo ${{ matrix.mongodb-version }}, "
},
{
"path": ".gitignore",
"chars": 74,
"preview": "/node_modules\n/build\n/types\n/coverage\n/.nyc_output\n.idea\n.npmrc\n.DS_Store\n"
},
{
"path": ".nvmrc",
"chars": 3,
"preview": "16\n"
},
{
"path": ".nycrc",
"chars": 324,
"preview": "{\n\t\"extends\": \"@istanbuljs/nyc-config-typescript\",\n\t\"all\": true,\n\t\"exclude\": [\n\t\t\"build\",\n\t\t\"bench\",\n\t\t\"coverage\",\n\t\t\"di"
},
{
"path": "LICENSE",
"chars": 1078,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2013 Jason Raede\n\nPermission is hereby granted, free of charge, to any person obtai"
},
{
"path": "README.md",
"chars": 26068,
"preview": "# medici\n\n<div align=\"center\">\n\n["
},
{
"path": "bench/bench-balance.ts",
"chars": 1332,
"preview": "import * as mongoose from \"mongoose\";\nimport { MongoMemoryReplSet } from \"mongodb-memory-server\";\nimport { Book, initMod"
},
{
"path": "bench/bench-ledger.ts",
"chars": 1365,
"preview": "import * as mongoose from \"mongoose\";\nimport { MongoMemoryReplSet } from \"mongodb-memory-server\";\nimport { Book, initMod"
},
{
"path": "package.json",
"chars": 3060,
"preview": "{\n \"name\": \"medici\",\n \"version\": \"7.2.0\",\n \"description\": \"Double-entry accounting ledger for Node + Mongoose\",\n \"ma"
},
{
"path": "spec/balance.spec.ts",
"chars": 10920,
"preview": "/* eslint sonarjs/no-duplicate-string: off, no-prototype-builtins: off*/\nimport { expect } from \"chai\";\nimport { Book, s"
},
{
"path": "spec/book.spec.ts",
"chars": 33014,
"preview": "/* eslint sonarjs/no-duplicate-string: off, @typescript-eslint/no-non-null-assertion: off, no-prototype-builtins: off*/\n"
},
{
"path": "spec/constructKey.spec.ts",
"chars": 951,
"preview": "import { expect } from \"chai\";\nimport { constructKey } from \"../src/models/balance\";\n\ndescribe(\"constructKey\", () => {\n "
},
{
"path": "spec/extractObjectIdKeysFromSchema.spec.ts",
"chars": 615,
"preview": "import { expect } from \"chai\";\nimport { Schema } from \"mongoose\";\nimport { extractObjectIdKeysFromSchema } from \"../src/"
},
{
"path": "spec/fpPrecision.spec.ts",
"chars": 2852,
"preview": "/* eslint sonarjs/no-duplicate-string: off, no-prototype-builtins: off*/\nimport { expect } from \"chai\";\nimport { Book } "
},
{
"path": "spec/handleVoidMemo.spec.ts",
"chars": 909,
"preview": "/* eslint sonarjs/no-duplicate-string: off, @typescript-eslint/no-non-null-assertion: off, security/detect-object-inject"
},
{
"path": "spec/helper/MongoDB.spec.ts",
"chars": 1619,
"preview": "import { before, after } from \"mocha\";\nimport * as mongoose from \"mongoose\";\nimport { MongoMemoryReplSet } from \"mongodb"
},
{
"path": "spec/helper/delay.ts",
"chars": 82,
"preview": "export default (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n"
},
{
"path": "spec/helper/transactionSchema.ts",
"chars": 1463,
"preview": "import { Schema, Types } from \"mongoose\";\nimport { IAnyObject } from \"../../src/IAnyObject\";\n\nexport interface ITransact"
},
{
"path": "spec/index.spec.ts",
"chars": 32,
"preview": "import \"./helper/MongoDB.spec\";\n"
},
{
"path": "spec/parseBalanceQuery.spec.ts",
"chars": 8394,
"preview": "/* eslint sonarjs/no-duplicate-string: off, @typescript-eslint/no-non-null-assertion: off */\nimport { expect } from \"cha"
},
{
"path": "spec/parseDateField.spec.ts",
"chars": 2181,
"preview": "/* eslint @typescript-eslint/no-non-null-assertion: off */\nimport { expect } from \"chai\";\nimport { parseDateField } from"
},
{
"path": "spec/parseFilterQuery.spec.ts",
"chars": 7791,
"preview": "/* eslint sonarjs/no-duplicate-string: off, @typescript-eslint/no-non-null-assertion: off */\nimport { expect } from \"cha"
},
{
"path": "spec/safeSetKeyToMetaObject.spec.ts",
"chars": 3761,
"preview": "/* eslint sonarjs/no-duplicate-string: off */\nimport { expect } from \"chai\";\nimport { Schema, Types } from \"mongoose\";\ni"
},
{
"path": "spec/setTransactionSchema.spec.ts",
"chars": 1994,
"preview": "/* eslint sonarjs/no-duplicate-string: off */\nimport { expect } from \"chai\";\nimport { Types } from \"mongoose\";\nimport { "
},
{
"path": "spec/types/medici.spec-d.ts",
"chars": 2144,
"preview": "/* eslint import/no-unresolved: off */\nimport { expectError, expectType } from \"tsd\";\nimport BookESM, { Book, Entry, set"
},
{
"path": "spec/xacid.spec.ts",
"chars": 18840,
"preview": "/* eslint sonarjs/no-duplicate-string: off, sonarjs/no-identical-functions: off */\nimport { Book } from \"../src/Book\";\ni"
},
{
"path": "src/Book.ts",
"chars": 14514,
"preview": "import { Types } from \"mongoose\";\nimport {\n JournalAlreadyVoidedError,\n MediciError,\n ConsistencyError,\n JournalNotF"
},
{
"path": "src/Entry.ts",
"chars": 6545,
"preview": "import { Types } from \"mongoose\";\nimport { TransactionError, InvalidAccountPathLengthError } from \"./errors\";\nimport typ"
},
{
"path": "src/IAnyObject.ts",
"chars": 105,
"preview": "/* eslint @typescript-eslint/no-explicit-any: off */\nexport interface IAnyObject {\n [k: string]: any;\n}\n"
},
{
"path": "src/IOptions.ts",
"chars": 326,
"preview": "import type { ClientSession } from \"mongoose\";\nimport type { ReadPreferenceLike, Hint, ReadConcernLike } from \"mongodb\";"
},
{
"path": "src/errors/BookConstructorError.ts",
"chars": 217,
"preview": "import { MediciError } from \"./MediciError\";\n\nexport class BookConstructorError extends MediciError {\n public code = 40"
},
{
"path": "src/errors/ConsistencyError.ts",
"chars": 241,
"preview": "import { MediciError } from \"./MediciError\";\n\nexport class ConsistencyError extends MediciError {\n public code = 400;\n\n"
},
{
"path": "src/errors/InvalidAccountPathLengthError.ts",
"chars": 226,
"preview": "import { MediciError } from \"./MediciError\";\n\nexport class InvalidAccountPathLengthError extends MediciError {\n public "
},
{
"path": "src/errors/JournalAlreadyVoidedError.ts",
"chars": 242,
"preview": "import { MediciError } from \"./MediciError\";\n\nexport class JournalAlreadyVoidedError extends MediciError {\n public code"
},
{
"path": "src/errors/JournalNotFoundError.ts",
"chars": 241,
"preview": "import { MediciError } from \"./MediciError\";\n\nexport class JournalNotFoundError extends MediciError {\n public code = 40"
},
{
"path": "src/errors/MediciError.ts",
"chars": 156,
"preview": "export class MediciError extends Error {\n public code = 500;\n\n constructor(message: string, code = 500) {\n super(me"
},
{
"path": "src/errors/TransactionError.ts",
"chars": 276,
"preview": "import { MediciError } from \"./MediciError\";\n\nexport class TransactionError extends MediciError {\n public code = 400;\n "
},
{
"path": "src/errors/index.ts",
"chars": 435,
"preview": "export { MediciError } from \"./MediciError\";\nexport { BookConstructorError } from \"./BookConstructorError\";\nexport { Con"
},
{
"path": "src/helper/addReversedTransactions.ts",
"chars": 927,
"preview": "import { Entry } from \"..\";\nimport { IAnyObject } from \"../IAnyObject\";\nimport { ITransaction } from \"../models/transact"
},
{
"path": "src/helper/extractObjectIdKeysFromSchema.ts",
"chars": 304,
"preview": "import { Schema } from \"mongoose\";\n\nexport function extractObjectIdKeysFromSchema(schema: Schema) {\n const result: Set<"
},
{
"path": "src/helper/flattenObject.ts",
"chars": 444,
"preview": "import { IAnyObject } from \"../IAnyObject\";\n\nexport function flattenObject(obj?: IAnyObject, parent?: string, deep = fal"
},
{
"path": "src/helper/handleVoidMemo.ts",
"chars": 577,
"preview": "const voidRE = /^\\[VOID\\]/;\nconst unvoidRE = /^\\[UNVOID\\]/;\nconst revoidRE = /^\\[REVOID\\]/;\n\nexport function handleVoidM"
},
{
"path": "src/helper/initModels.ts",
"chars": 359,
"preview": "import { journalModel } from \"../models/journal\";\nimport { transactionModel } from \"../models/transaction\";\nimport { loc"
},
{
"path": "src/helper/isPrototypeAttribute.ts",
"chars": 461,
"preview": "const reservedWords: Set<string> = new Set([\n \"__proto__\",\n \"__defineGetter__\",\n \"__lookupGetter__\",\n \"__defineSette"
},
{
"path": "src/helper/mongoTransaction.ts",
"chars": 299,
"preview": "/* eslint require-await: off */\nimport { ClientSession, connection } from \"mongoose\";\nimport { IAnyObject } from \"../IAn"
},
{
"path": "src/helper/parse/IFilter.ts",
"chars": 97,
"preview": "import { Collection } from \"mongoose\";\n\nexport type IFilter = Parameters<Collection[\"find\"]>[0];\n"
},
{
"path": "src/helper/parse/parseAccountField.ts",
"chars": 864,
"preview": "import type { IFilter } from \"./IFilter\";\n\nexport function parseAccountField(account: string | string[] | undefined, max"
},
{
"path": "src/helper/parse/parseBalanceQuery.ts",
"chars": 1748,
"preview": "import { Types } from \"mongoose\";\nimport type { Book } from \"../../Book\";\nimport { isPrototypeAttribute } from \"../isPro"
},
{
"path": "src/helper/parse/parseDateField.ts",
"chars": 970,
"preview": "const numberRE = /^\\d+$/;\n\nexport function parseDateField(value: unknown): Date | undefined {\n if (value instanceof Dat"
},
{
"path": "src/helper/parse/parseFilterQuery.ts",
"chars": 1822,
"preview": "import { Types } from \"mongoose\";\nimport type { Book } from \"../../Book\";\nimport { isTransactionObjectIdKey, isValidTran"
},
{
"path": "src/helper/safeSetKeyToMetaObject.ts",
"chars": 423,
"preview": "import { isValidTransactionKey, defaultTransactionSchemaKeys } from \"../models/transaction\";\nimport { isPrototypeAttribu"
},
{
"path": "src/helper/syncIndexes.ts",
"chars": 656,
"preview": "import { journalModel } from \"../models/journal\";\nimport { lockModel } from \"../models/lock\";\nimport { transactionModel "
},
{
"path": "src/index.ts",
"chars": 864,
"preview": "import { Book } from \"./Book\";\nimport type { Entry } from \"./Entry\";\n\nexport { setJournalSchema } from \"./models/journal"
},
{
"path": "src/models/balance.ts",
"chars": 3191,
"preview": "import { createHash } from \"crypto\";\nimport { Schema, model, Model, connection, Types, FilterQuery } from \"mongoose\";\nim"
},
{
"path": "src/models/journal.ts",
"chars": 1167,
"preview": "import { connection, Schema, Document, Model, model, Types } from \"mongoose\";\n\nexport interface IJournal {\n _id: Types."
},
{
"path": "src/models/lock.ts",
"chars": 853,
"preview": "import { Schema, model, Model, connection } from \"mongoose\";\n\nexport interface ILock {\n account: string;\n book: string"
},
{
"path": "src/models/transaction.ts",
"chars": 2755,
"preview": "import { connection, Schema, model, Model, Types } from \"mongoose\";\nimport { extractObjectIdKeysFromSchema } from \"../he"
},
{
"path": "tsconfig.eslint.json",
"chars": 266,
"preview": "{\n \"display\": \"Node 16\",\n \"compilerOptions\": {\n \"lib\": [\"es2021\"],\n \"module\": \"commonjs\",\n \"target\": \"es2021\""
},
{
"path": "tsconfig.json",
"chars": 406,
"preview": "{\n \"display\": \"Node 16\",\n \"compilerOptions\": {\n \"lib\": [\"es2021\"],\n \"module\": \"commonjs\",\n \"target\": \"es2021\""
},
{
"path": "tsconfig.types.json",
"chars": 633,
"preview": "{\n\t\"display\": \"Typings\",\n\t\"compilerOptions\": {\n\t\t\"sourceMap\": true,\n\t\t\"noImplicitAny\": false,\n\t\t\"declaration\": true,\n\t\t\""
}
]
About this extraction
This page contains the full source code of the flash-oss/medici GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 63 files (172.9 KB), approximately 45.5k tokens, and a symbol index with 76 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.