Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.",'Learn how to use',"
","
"+appName+" API requires the following scopes. Select which ones you want to grant to Swagger UI.
",'
',"
",'','',"
",""].join("")),$(document.body).append(popupDialog),popup=popupDialog.find("ul.api-popup-scopes").empty(),p=0;p",popup.append(str);var r=$(window),s=r.width(),c=r.height(),l=r.scrollTop(),d=popupDialog.outerWidth(),u=popupDialog.outerHeight(),h=(c-u)/2+l,g=(s-d)/2;popupDialog.css({top:(h<0?0:h)+"px",left:(g<0?0:g)+"px"}),popupDialog.find("button.api-popup-cancel").click(function(){popupMask.hide(),popupDialog.hide(),popupDialog.empty(),popupDialog=[]}),$("button.api-popup-authbtn").unbind(),popupDialog.find("button.api-popup-authbtn").click(function(){function e(e){return e.vendorExtensions["x-tokenName"]||e.tokenName}popupMask.hide(),popupDialog.hide();var o,i=window.swaggerUi.api.authSchemes,n=window.location,a=location.pathname.substring(0,location.pathname.lastIndexOf("/")),t=n.protocol+"//"+n.host+a+"/o2c.html",p=window.oAuthRedirectUrl||t,r=null,s=[],c=popup.find("input:checked"),l=[];for(k=0;k0?void log("auth unable initialize oauth: "+i):($("pre code").each(function(e,o){hljs.highlightBlock(o)}),$(".api-ic").unbind(),void $(".api-ic").click(function(e){$(e.target).hasClass("ic-off")?handleLogin():handleLogout()}))}function clientCredentialsFlow(e,o,i){var n={client_id:clientId,client_secret:clientSecret,scope:e.join(" "),grant_type:"client_credentials"};$.ajax({url:o,type:"POST",data:n,success:function(e,o,n){onOAuthComplete(e,i)},error:function(e,o,i){onOAuthComplete("")}})}var appName,popupMask,popupDialog,clientId,realm,redirect_uri,clientSecret,scopeSeparator,additionalQueryStringParams;window.processOAuthCode=function(e){var o=e.state,i=window.location,n=location.pathname.substring(0,location.pathname.lastIndexOf("/")),a=i.protocol+"//"+i.host+n+"/o2c.html",t=window.oAuthRedirectUrl||a,p={client_id:clientId,code:e.code,grant_type:"authorization_code",redirect_uri:t};clientSecret&&(p.client_secret=clientSecret),$.ajax({url:window.swaggerUiAuth.tokenUrl,type:"POST",data:p,success:function(e,i,n){onOAuthComplete(e,o)},error:function(e,o,i){onOAuthComplete("")}})},window.onOAuthComplete=function(e,o){if(e)if(e.error){var i=$("input[type=checkbox],.secured");i.each(function(e){i[e].checked=!1}),alert(e.error)}else{var n=e[window.swaggerUiAuth.tokenName];if(o||(o=e.state),n){var a=null;$.each($(".auth .api-ic .api_information_panel"),function(e,o){var i=o;if(i&&i.childNodes){var n=[];$.each(i.childNodes,function(e,o){var i=o.innerHTML;i&&n.push(i)});for(var t=[],p=0;p0?(a=o.parentNode.parentNode,$(a.parentNode).find(".api-ic.ic-on").addClass("ic-off"),$(a.parentNode).find(".api-ic.ic-on").removeClass("ic-on"),$(a).find(".api-ic").addClass("ic-warning"),$(a).find(".api-ic").removeClass("ic-error")):(a=o.parentNode.parentNode,$(a.parentNode).find(".api-ic.ic-off").addClass("ic-on"),$(a.parentNode).find(".api-ic.ic-off").removeClass("ic-off"),$(a).find(".api-ic").addClass("ic-info"),$(a).find(".api-ic").removeClass("ic-warning"),$(a).find(".api-ic").removeClass("ic-error"))}}),"undefined"!=typeof window.swaggerUi&&(window.swaggerUi.api.clientAuthorizations.add(window.swaggerUiAuth.OAuthSchemeKey,new SwaggerClient.ApiKeyAuthorization("Authorization","Bearer "+n,"header")),window.swaggerUi.load())}}};
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # This repo is no longer maintained.
2 |
3 | # A=L+E
4 | Multi-currency double-entry accounting system based on node.js + Sequelize
5 |
6 | Ale is based on the basic formula `Assets = Liabilities + Equity` and is insipred by [Medici](https://github.com/koresar/medici)
7 |
8 | ## Standalone Server
9 | A.L.E. operates as a standalone microservice.
10 |
11 | Configure your database and after setting the `ALE_CONNECTION` envar, simply run
12 |
13 | `$ node server.js`
14 |
15 | to launch a REST server to handle all your double-entry accounting needs.
16 | You can peruse the [API documentation](https://cjs77.github.io/ale/) for more details on how to use the service.
17 |
18 |
19 |
20 | ## Programmatic API
21 | A.L.E. can also be used as a node module. This section describes how to manage multi-currency accounting programmatically.
22 |
23 | ALE divides itself into "books", each of which store *journal entries* and their child *transactions*. The cardinal rule of double-entry accounting is that "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 return a rejected promise.
24 |
25 | 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 field in `Transactions` and `JournalEntry` tables to allow you to have multiple books if you want to.
26 |
27 | Each transaction in ALE operates on a single *account*. Accounts are arbitrary string tags, but you can subdivide accounts using colons (or any other separator). Transactions in 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).
28 |
29 | 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. Sub-accounts are also not matched explicitly, but by comparing the account name to query against the beginning of the account name. Thus `Trades` will match `Trades:USD`, but not `Assets:Trades`.
30 |
31 |
32 | ## Configuration
33 |
34 | ALE tries to be agnostic as to which RDMS you have running on the back-end. Therefore, you need to ensure that the relevant DB bindings are installed and part of your `package.json` file.
35 |
36 | For PostgreSQL, this would entail
37 |
38 | `yarn add pg`
39 |
40 | You *must* set an `ALE_CONNECTION` environment variable which holds the connection string to connect to the underlying database. To date,
41 | ALE has been tested against PostgreSQL, but any DB supported by Sequelize (SQLite, MySQL etc) should work.
42 |
43 | `export ALE_CONNECTION=postres://ale_user@localhost:5432/trading_db`
44 |
45 | The user and database must exist before importing ALE. For an example of how to ensure this in code (for PostgreSQL), see the test setup function.
46 |
47 |
48 | ## Coding Standards
49 |
50 | ALE is written in JavaScript. All database queries return promise objects instead of using the traditional node `function(err, result)`callback.
51 |
52 | ## Writing journal entries
53 |
54 | Writing a journal entry is very simple. First you need a `book` object:
55 |
56 | ```js
57 | const { Book } = require('a.l.e');
58 |
59 | // The first argument is the book name, which is used to determine which book the transactions and journals are queried from.
60 | // The second argument is the currency to report all queries in.
61 | return Book.getOrCreateBook('ACME inc', 'USD').then(book => {
62 | ...
63 | });
64 | ```
65 |
66 | Now write an entry:
67 |
68 | ```js
69 | // 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 right now
70 | const newEntry= book.newJournalEntry('Received payment');
71 | newEntry.debit('Assets:Receivable', 500) // The currency and exchange rate default to 'USD' and 1.0 if omitted
72 | .credit('Income:Rent', 500, 'USD'); // debit and credit return the entry to allow chained transactions
73 | entry.commit().then((e) => { // Validate and commit the entry to the DB, returning a promise
74 | assert.equal(e.memo, 'Received payment');
75 | });
76 | ```
77 |
78 | 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, currency, exchangeRate).
79 |
80 | ## Querying Account Balance
81 |
82 | To query account balance, just use the `book.balance()` method:
83 |
84 | ```js
85 | myBook.balance({ account:'Assets:Accounts Receivable' }).then((balance) => {
86 | console.log("Joe Blow owes me", balance.total);
87 | });
88 | ```
89 |
90 | `balance` returns a promise that resolves to the the following `creditTotal` - the sum of credits (in quote currency), `debitTotal`, `balance` - The current balance, in quote currency, `currency` - the currency the results are reported in, `numTransactions` - The total number of transactions making up the balance calculation.
91 |
92 | ## Retrieving Transactions
93 |
94 | To retrieve transactions, use the `book.ledger()` method (here I'm using moment.js for dates):
95 |
96 | ```js
97 | const startDate = moment().subtract('months', 1).toDate(); // One month ago
98 | const endDate = new Date(); //today
99 |
100 | myBook.ledger({
101 | account: ['Income', 'Expenses'] // Both sets of accounts will be included
102 | start_date: startDate,
103 | end_date: endDate
104 | }).then((result) => {
105 | // Do something with the returned transaction documents
106 | result.count // The number of transactions found
107 | result.transactions // An array of transactions found
108 | });
109 | ```
110 |
111 | ## Voiding Journal Entries
112 |
113 | 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, ALE 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.
114 |
115 | To void a journal entry, you can either call the `voidEntry(book, void_reason)` method on a JournalEntry instance document, or use the `book.voidEntry(journalId, void_reason)` method if you know the journal document's ID.
116 |
117 | ```js
118 | myBook.void("123456", "I made a mistake").then(() => {
119 | // Do something after voiding
120 | })
121 | ```
122 |
123 | The original entry will have its `voided` field set to true and counteracting transctions will be added with the original memo suffixed with `[REVERSED]`
124 |
125 |
126 | ## Calculating unrealized profit with `markToMarket`
127 |
128 | You can calculated unrealised profit/losses due to changes in exhange rates by calling `markToMarket` and giving it a hash of exchange rates:
129 |
130 | ```
131 | const book = new Book('Forex test');
132 | book.markToMarket({ account: ['Trading', 'Assets:Bank'] }, { ZAR: 20, USD: 1 }).then(result => {
133 | assert.equal(result['Trading:ZAR'], 500);
134 | assert.equal(result['Trading:USD'], 600);
135 | assert.equal(result['Assets:Bank'], -1650);
136 | assert.equal(result.unrealizedProfit, -550);
137 | });
138 | ```
139 |
140 | ## Changelog
141 |
142 | * **v0.1.0** First release
143 | * **v1.0.0** REST API release
144 |
--------------------------------------------------------------------------------
/models/journal.js:
--------------------------------------------------------------------------------
1 | /*
2 | * @license
3 | * Copyright 2017 Cayle Sharrock
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
6 | * with the License. You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
11 | * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12 | * License for the specific language governing permissions and limitations under the License.
13 | */
14 | const Sequelize = require('sequelize');
15 | const sequelize = require('./connection');
16 | const Transaction = require('./transaction');
17 | const {NEAR_ZERO} = require('./types');
18 | const {AleError, codes} = require('../lib/errors');
19 |
20 | /**
21 | * A journal entry comprises a set of transactions, which have a balance set of debit and credit entries
22 | */
23 | const JournalEntry = sequelize.define('journal', {
24 | memo: {type: Sequelize.TEXT, defaultValue: ''},
25 | timestamp: {type: Sequelize.DATE, validate: {isDate: true}, defaultValue: Date.now},
26 | voided: {type: Sequelize.BOOLEAN, defaultValue: false},
27 | voidReason: Sequelize.STRING
28 | }, {
29 | name: {
30 | singular: 'JournalEntry',
31 | plural: 'JournalEntries'
32 | }
33 | }
34 | );
35 |
36 | Transaction.JournalEntry = Transaction.belongsTo(JournalEntry, {foreignKey: {allowNull: false}, onDelete: 'CASCADE'});
37 | JournalEntry.hasOne(JournalEntry, {as: 'Original'});
38 | JournalEntry.hasMany(Transaction, {foreignKey: {allowNull: false}, onDelete: 'CASCADE'});
39 |
40 | JournalEntry.prototype.values = function() {
41 | return {
42 | id: this.getDataValue('id'),
43 | memo: this.getDataValue('memo'),
44 | timestamp: new Date(this.getDataValue('timestamp')),
45 | voided: this.getDataValue('voided'),
46 | voidReason: this.getDataValue('voidReason'),
47 | OriginalId: this.getDataValue('OriginalId')
48 | };
49 | };
50 |
51 | JournalEntry.prototype.voidEntry = function(book, reason) {
52 | if (this.voided === true) {
53 | return sequelize.Promise.reject(new Error('Journal entry already voided'));
54 | }
55 | return sequelize.transaction({autocommit: false}, t => {
56 | // Set this to void with reason and also set all associated transactions
57 | this.voided = true;
58 | this.voidReason = !reason ? '' : reason;
59 | return this.save({transaction: t}).then(oldEntry => {
60 | return oldEntry.getTransactions();
61 | }).then(txs => {
62 | return sequelize.Promise.map(txs, tx => {
63 | tx.voided = true;
64 | tx.voidReason = reason;
65 | return tx.save({transaction: t});
66 | });
67 | }).then(txs => {
68 | let newMemo = `${this.memo} [REVERSED]`;
69 | // Ok now create an equal and opposite journal
70 | const newEntry = book.newJournalEntry(newMemo, new Date());
71 | newEntry.setDataValue('OriginalId', this.getDataValue('id'));
72 | for (let tx of txs) {
73 | if (+tx.credit !== 0) {
74 | newEntry.debit(tx.account, tx.credit, tx.currency, +tx.exchangeRate);
75 | }
76 | if (+tx.debit !== 0) {
77 | newEntry.credit(tx.account, tx.debit, tx.currency, tx.exchangeRate);
78 | }
79 | }
80 | const total = this.calculatePendingTotal(txs);
81 | if (Math.abs(total) > NEAR_ZERO) {
82 | return sequelize.Promise.reject(new AleError('Invalid Journal Entry Reversal. Total not zero', codes.EntryNotBalanced));
83 | }
84 | return newEntry._saveEntryWithTxs(t);
85 | });
86 | }).then(() => {
87 | //transaction has been committed
88 | return true;
89 | }).catch(e => {
90 | const err = new AleError(`Voiding entry failed. ${e.message}`, codes.DatabaseUpdateError);
91 | return sequelize.Promise.reject(err);
92 | });
93 | };
94 |
95 | JournalEntry.prototype.newTransaction = function(account, amount, isCredit, currency, exchangeRate = 1.0) {
96 | amount = +amount;
97 | if (typeof account === 'string') {
98 | account = account.split(':');
99 | }
100 |
101 | if (account.length > 3) {
102 | const err = new Error('Account path is too deep (maximum 3)');
103 | err.accountPath = account;
104 | throw err;
105 | }
106 |
107 | const transaction = Transaction.build({
108 | account: account.join(':'),
109 | credit: isCredit ? amount : 0.0,
110 | debit: isCredit ? 0.0 : amount,
111 | exchangeRate: +exchangeRate,
112 | currency: currency,
113 | timestamp: new Date(),
114 | bookId: this.getDataValue('bookId')
115 | });
116 | if (!this.pendingTransactions) {
117 | this.pendingTransactions = [];
118 | }
119 | this.pendingTransactions.push(transaction);
120 |
121 | return this;
122 | };
123 |
124 | JournalEntry.prototype.debit = function(account, amount, currency = undefined, exchangeRate = 1.0) {
125 | return this.newTransaction(account, amount, false, currency, exchangeRate);
126 | };
127 |
128 | JournalEntry.prototype.credit = function(account, amount, currency = undefined, exchangeRate = 1.) {
129 | return this.newTransaction(account, amount, true, currency, exchangeRate);
130 | };
131 |
132 | JournalEntry.prototype.commit = function() {
133 | const total = this.calculatePendingTotal();
134 | if (Math.abs(total) > NEAR_ZERO) {
135 | return sequelize.Promise.reject(new AleError('Invalid Journal Entry. Total not zero', codes.EntryNotBalanced));
136 | }
137 | return sequelize.transaction({autocommit: false}, t => {
138 | return this._saveEntryWithTxs(t);
139 | }).then(result => {
140 | // Transaction has committed
141 | return result;
142 | }).catch(e => {
143 | // Transaction has rolled back
144 | const err = new AleError(e.message, codes.DatabaseUpdateError);
145 | return sequelize.Promise.reject(err);
146 | });
147 | };
148 |
149 | JournalEntry.prototype._saveEntryWithTxs = function(t) {
150 | let result;
151 | return this.save({transaction: t}).then(e => {
152 | result = e;
153 | const id = result.getDataValue('id');
154 | return this.saveTransactions(id, {transaction: t});
155 | }).then(() => {
156 | return sequelize.Promise.resolve(result);
157 | });
158 | };
159 |
160 | JournalEntry.prototype.calculatePendingTotal = function(txs) {
161 | const transactions = txs || this.pendingTransactions;
162 | if (!transactions || !Array.isArray(transactions) || transactions.length === 0) {
163 | return;
164 | }
165 | let total = 0.0;
166 | for (let tx of transactions) {
167 | total = total + tx.getDataValue('credit') * tx.getDataValue('exchangeRate');
168 | total = total - tx.getDataValue('debit') * tx.getDataValue('exchangeRate');
169 | }
170 | return total;
171 | };
172 |
173 | JournalEntry.prototype.saveTransactions = function(id, opts) {
174 | const transactions = this.pendingTransactions;
175 | return sequelize.Promise.map(transactions, tx => {
176 | tx.setDataValue('JournalEntryId', id);
177 | return tx.save(opts);
178 | }).catch(e => {
179 | return sequelize.Promise.reject(new AleError(`Failed to save transactions for Entry: ${this.getDataValue('memo')} ${e.message}`, codes.DatabaseUpdateError));
180 | });
181 | };
182 |
183 | module.exports = JournalEntry;
184 |
--------------------------------------------------------------------------------
/test/1_test.js:
--------------------------------------------------------------------------------
1 | /*
2 | * @license
3 | * Copyright 2017 Cayle Sharrock
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
6 | * with the License. You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
11 | * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12 | * License for the specific language governing permissions and limitations under the License.
13 | */
14 |
15 | const assert = require('assert');
16 | const {codes} = require('../lib/errors');
17 | const testDB = require('./testDB');
18 |
19 | let Book;
20 | describe('ALE', () => {
21 | let bookZAR, bookUSD, sequelize;
22 |
23 | before(done => {
24 | testDB.clear().then(() => {
25 | done();
26 | });
27 | });
28 |
29 | before(() => {
30 | sequelize = require('../models/connection');
31 | Book = require('../models/book');
32 | return sequelize.sync();
33 | });
34 |
35 | let entry1 = null;
36 | it('Should let you define new books', () => {
37 | return Book.getOrCreateBook('TestB', 'USD').then(res => {
38 | bookUSD = res.book;
39 | assert.equal(bookUSD.name, 'TestB');
40 | assert.equal(bookUSD.quoteCurrency, 'USD');
41 | return Book.getOrCreateBook('TestA', 'ZAR');
42 | }).then(res => {
43 | bookZAR = res.book;
44 | assert.equal(bookZAR.name, 'TestA');
45 | assert.equal(bookZAR.quoteCurrency, 'ZAR');
46 | return Book.listBooks();
47 | }).then(books => {
48 | assert.equal(books.length, 2);
49 | assert.equal(books[0].name, 'TestA');
50 | assert.equal(books[1].name, 'TestB');
51 | });
52 | });
53 |
54 | it('Requesting a book that exists returns that book', () => {
55 | return Book.getOrCreateBook('TestA', 'ZAR').then(res => {
56 | assert.equal(res.isNew, false);
57 | assert.equal(res.book.name, 'TestA');
58 | assert.equal(res.book.quoteCurrency, 'ZAR');
59 | return Book.listBooks();
60 | }).then(books => {
61 | assert.equal(books.length, 2);
62 | assert.equal(books[0].name, 'TestA');
63 | assert.equal(books[1].name, 'TestB');
64 | });
65 | });
66 |
67 | it('Requesting a book with conflicting base currencies throws an error', () => {
68 | return Book.getOrCreateBook('TestA', 'THB').then(() => {
69 | throw new Error('Request should fail');
70 | }, err => {
71 | assert(err);
72 | assert.equal(err.code, codes.MismatchedCurrency);
73 | });
74 | });
75 |
76 | it('should return a list of books', () => {
77 | return Book.listBooks().then(books => {
78 | assert.equal(books.length, 2);
79 | assert.equal(books[0].name, 'TestA');
80 | assert.equal(books[0].quoteCurrency, 'ZAR');
81 | assert.equal(books[1].name, 'TestB');
82 | assert.equal(books[1].quoteCurrency, 'USD');
83 | });
84 | });
85 |
86 | it('Should let you create a basic transaction', () => {
87 | const entry = bookZAR.newJournalEntry('Test Entry');
88 | entry.debit('Assets:Receivable', 500, 'ZAR')
89 | .credit('Income:Rent', 500, 'ZAR');
90 | return entry.commit().then((e) => {
91 | entry1 = e;
92 | assert.equal(e.memo, 'Test Entry');
93 | return e.getTransactions();
94 | }).then((txs) => {
95 | assert.equal(txs.length, 2);
96 | const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
97 | const entry2 = bookZAR.newJournalEntry('Test Entry 2', threeDaysAgo);
98 | return entry2
99 | .debit('Assets:Receivable', 700)
100 | .credit('Income:Rent', 700)
101 | .commit();
102 | }).then(entry2 => {
103 | assert.equal(entry2.memo, 'Test Entry 2');
104 | return entry2.getTransactions();
105 | }).then(txs => {
106 | txs = txs.sort((a, b) => a.account > b.account ? 1 : -1);
107 | assert.equal(txs.length, 2);
108 | assert.equal(txs[0].exchangeRate, 1.0);
109 | assert.equal(txs[0].debit, 700);
110 | assert.equal(txs[0].credit, 0);
111 | assert.equal(txs[1].credit, 700);
112 | assert.equal(txs[1].debit, 0);
113 | });
114 | });
115 |
116 | it('Should deal with rounding errors', () => {
117 | const entry = bookUSD.newJournalEntry('Rounding Test');
118 | entry.credit('A:B', 1005, 'USD')
119 | .debit('A:B', 994.95, 'USD')
120 | .debit('A:B', 10.05, 'USD');
121 | return entry.commit().then(() => {
122 | return bookUSD.getBalance({account: 'A:B'});
123 | }).then((balance) => {
124 | assert.equal(balance.balance, 0);
125 | assert.equal(balance.creditTotal, 1005);
126 | assert.equal(balance.debitTotal, 1005);
127 | assert.equal(balance.numTransactions, 3);
128 | assert.equal(balance.currency, 'USD');
129 | });
130 | });
131 |
132 | it('Should have updated the balance for assets and income and accurately give balance for sub-accounts', () => {
133 | return bookZAR.getBalance({account: 'Assets'}).then((data) => {
134 | assert.equal(data.numTransactions, 2);
135 | assert.equal(data.balance, -1200);
136 | return bookZAR.getBalance({account: 'Assets:Receivable'});
137 | }).then((data) => {
138 | assert.equal(data.numTransactions, 2);
139 | assert.equal(data.balance, -1200);
140 | return bookZAR.getBalance({account: 'Assets:Other'});
141 | }).then((data) => {
142 | assert.equal(data.numTransactions, 0);
143 | assert.equal(data.balance, 0);
144 | });
145 | });
146 |
147 | it('should return full ledger', () => {
148 | return bookZAR.getLedger().then(res => {
149 | assert.equal(res.length, 2);
150 | });
151 | });
152 |
153 | it('should allow you to void a journal entry', () => {
154 | return bookZAR.getJournalEntries({memo: 'Test Entry'}).then((txs) => {
155 | const id = txs[0].id;
156 | return bookZAR.voidEntry(id, 'Messed up');
157 | }).then(() => {
158 | return bookZAR.getBalance({account: 'Assets'});
159 | }).then((data) => {
160 | assert.equal(data.balance, -700);
161 | });
162 | });
163 |
164 | it('should list all accounts', () => {
165 | return bookZAR.listAccounts().then((accounts) => {
166 | assert(accounts.indexOf('Assets') > -1);
167 | assert(accounts.indexOf('Assets:Receivable') > -1);
168 | assert(accounts.indexOf('Income') > -1);
169 | assert(accounts.indexOf('Income:Rent') > -1);
170 | });
171 | });
172 |
173 | it('should return ledger with array of accounts', () => {
174 | return bookZAR.getTransactions({
175 | account: ['Assets', 'Income']
176 | }).then((result) => {
177 | assert.equal(result.length, 6);
178 | });
179 | });
180 |
181 | it('should give you a paginated ledger when requested', () => {
182 | return bookZAR.getTransactions({
183 | account: ['Assets', 'Income'],
184 | perPage: 4,
185 | page: 2
186 | })
187 | .then((result) => {
188 | assert.equal(result.length, 2);
189 | assert.equal(result[0].credit, 500);
190 | assert.equal(result[1].debit, 500);
191 | });
192 | });
193 |
194 | it('should return newest transactions first when requested', () => {
195 | return bookZAR.getJournalEntries()
196 | .then((res) => {
197 | assert(new Date(res[0].timestamp) < new Date(res[1].timestamp));
198 | })
199 | .then(() => bookZAR.getJournalEntries({newestFirst: true}))
200 | .then((res) => {
201 | assert(new Date(res[0].timestamp) > new Date(res[1].timestamp));
202 | });
203 | })
204 | });
205 |
--------------------------------------------------------------------------------
/web_deploy/swagger-ui/lib/highlight.9.1.0.pack.js:
--------------------------------------------------------------------------------
1 | !function(e){"undefined"!=typeof exports?e(exports):(self.hljs=e({}),"function"==typeof define&&define.amd&&define("hljs",[],function(){return self.hljs}))}(function(e){function r(e){return e.replace(/&/gm,"&").replace(//gm,">")}function t(e){return e.nodeName.toLowerCase()}function n(e,r){var t=e&&e.exec(r);return t&&0==t.index}function a(e){return/^(no-?highlight|plain|text)$/i.test(e)}function c(e){var r,t,n,c=e.className+" ";if(c+=e.parentNode?e.parentNode.className:"",t=/\blang(?:uage)?-([\w-]+)\b/i.exec(c))return E(t[1])?t[1]:"no-highlight";for(c=c.split(/\s+/),r=0,n=c.length;n>r;r++)if(E(c[r])||a(c[r]))return c[r]}function i(e,r){var t,n={};for(t in e)n[t]=e[t];if(r)for(t in r)n[t]=r[t];return n}function o(e){var r=[];return function n(e,a){for(var c=e.firstChild;c;c=c.nextSibling)3==c.nodeType?a+=c.nodeValue.length:1==c.nodeType&&(r.push({event:"start",offset:a,node:c}),a=n(c,a),t(c).match(/br|hr|img|input/)||r.push({event:"stop",offset:a,node:c}));return a}(e,0),r}function s(e,n,a){function c(){return e.length&&n.length?e[0].offset!=n[0].offset?e[0].offset"}function o(e){l+=""+t(e)+">"}function s(e){("start"==e.event?i:o)(e.node)}for(var u=0,l="",f=[];e.length||n.length;){var b=c();if(l+=r(a.substr(u,b[0].offset-u)),u=b[0].offset,b==e){f.reverse().forEach(o);do s(b.splice(0,1)[0]),b=c();while(b==e&&b.length&&b[0].offset==u);f.reverse().forEach(i)}else"start"==b[0].event?f.push(b[0].node):f.pop(),s(b.splice(0,1)[0])}return l+r(a.substr(u))}function u(e){function r(e){return e&&e.source||e}function t(t,n){return new RegExp(r(t),"m"+(e.cI?"i":"")+(n?"g":""))}function n(a,c){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var o={},s=function(r,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");o[t[0]]=[r,t[1]?Number(t[1]):1]})};"string"==typeof a.k?s("keyword",a.k):Object.keys(a.k).forEach(function(e){s(e,a.k[e])}),a.k=o}a.lR=t(a.l||/\b\w+\b/,!0),c&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=r(a.e)||"",a.eW&&c.tE&&(a.tE+=(a.e?"|":"")+c.tE)),a.i&&(a.iR=t(a.i)),void 0===a.r&&(a.r=1),a.c||(a.c=[]);var u=[];a.c.forEach(function(e){e.v?e.v.forEach(function(r){u.push(i(e,r))}):u.push("self"==e?a:e)}),a.c=u,a.c.forEach(function(e){n(e,a)}),a.starts&&n(a.starts,c);var l=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(r).filter(Boolean);a.t=l.length?t(l.join("|"),!0):{exec:function(){return null}}}}n(e)}function l(e,t,a,c){function i(e,r){for(var t=0;t";return c+=e+'">',c+r+i}function p(){if(!M.k)return r(B);var e="",t=0;M.lR.lastIndex=0;for(var n=M.lR.exec(B);n;){e+=r(B.substr(t,n.index-t));var a=b(M,n);a?(L+=a[1],e+=g(a[0],r(n[0]))):e+=r(n[0]),t=M.lR.lastIndex,n=M.lR.exec(B)}return e+r(B.substr(t))}function h(){var e="string"==typeof M.sL;if(e&&!y[M.sL])return r(B);var t=e?l(M.sL,B,!0,R[M.sL]):f(B,M.sL.length?M.sL:void 0);return M.r>0&&(L+=t.r),e&&(R[M.sL]=t.top),g(t.language,t.value,!1,!0)}function d(){return void 0!==M.sL?h():p()}function m(e,t){var n=e.cN?g(e.cN,"",!0):"";e.rB?(x+=n,B=""):e.eB?(x+=r(t)+n,B=""):(x+=n,B=t),M=Object.create(e,{parent:{value:M}})}function v(e,t){if(B+=e,void 0===t)return x+=d(),0;var n=i(t,M);if(n)return x+=d(),m(n,t),n.rB?0:t.length;var a=o(M,t);if(a){var c=M;c.rE||c.eE||(B+=t),x+=d();do M.cN&&(x+=""),L+=M.r,M=M.parent;while(M!=a.parent);return c.eE&&(x+=r(t)),B="",a.starts&&m(a.starts,""),c.rE?0:t.length}if(s(t,M))throw new Error('Illegal lexeme "'+t+'" for mode "'+(M.cN||"")+'"');return B+=t,t.length||1}var N=E(e);if(!N)throw new Error('Unknown language: "'+e+'"');u(N);var C,M=c||N,R={},x="";for(C=M;C!=N;C=C.parent)C.cN&&(x=g(C.cN,"",!0)+x);var B="",L=0;try{for(var S,A,k=0;M.t.lastIndex=k,S=M.t.exec(t),S;)A=v(t.substr(k,S.index-k),S[0]),k=S.index+A;for(v(t.substr(k)),C=M;C.parent;C=C.parent)C.cN&&(x+="");return{r:L,value:x,language:e,top:M}}catch(I){if(-1!=I.message.indexOf("Illegal"))return{r:0,value:r(t)};throw I}}function f(e,t){t=t||w.languages||Object.keys(y);var n={r:0,value:r(e)},a=n;return t.forEach(function(r){if(E(r)){var t=l(r,e,!1);t.language=r,t.r>a.r&&(a=t),t.r>n.r&&(a=n,n=t)}}),a.language&&(n.second_best=a),n}function b(e){return w.tabReplace&&(e=e.replace(/^((<[^>]+>|\t)+)/gm,function(e,r){return r.replace(/\t/g,w.tabReplace)})),w.useBR&&(e=e.replace(/\n/g," ")),e}function g(e,r,t){var n=r?C[r]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(n)&&a.push(n),a.join(" ").trim()}function p(e){var r=c(e);if(!a(r)){var t;w.useBR?(t=document.createElementNS("http://www.w3.org/1999/xhtml","div"),t.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/ /g,"\n")):t=e;var n=t.textContent,i=r?l(r,n,!0):f(n),u=o(t);if(u.length){var p=document.createElementNS("http://www.w3.org/1999/xhtml","div");p.innerHTML=i.value,i.value=s(u,o(p),n)}i.value=b(i.value),e.innerHTML=i.value,e.className=g(e.className,r,i.language),e.result={language:i.language,re:i.r},i.second_best&&(e.second_best={language:i.second_best.language,re:i.second_best.r})}}function h(e){w=i(w,e)}function d(){if(!d.called){d.called=!0;var e=document.querySelectorAll("pre code");Array.prototype.forEach.call(e,p)}}function m(){addEventListener("DOMContentLoaded",d,!1),addEventListener("load",d,!1)}function v(r,t){var n=y[r]=t(e);n.aliases&&n.aliases.forEach(function(e){C[e]=r})}function N(){return Object.keys(y)}function E(e){return e=(e||"").toLowerCase(),y[e]||y[C[e]]}var w={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},y={},C={};return e.highlight=l,e.highlightAuto=f,e.fixMarkup=b,e.highlightBlock=p,e.configure=h,e.initHighlighting=d,e.initHighlightingOnLoad=m,e.registerLanguage=v,e.listLanguages=N,e.getLanguage=E,e.inherit=i,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/},e.C=function(r,t,n){var a=e.inherit({cN:"comment",b:r,e:t,c:[]},n||{});return a.c.push(e.PWM),a.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e}),hljs.registerLanguage("json",function(e){var r={literal:"true false null"},t=[e.QSM,e.CNM],n={e:",",eW:!0,eE:!0,c:t,k:r},a={b:"{",e:"}",c:[{cN:"attr",b:'\\s*"',e:'"\\s*:\\s*',eB:!0,eE:!0,c:[e.BE],i:"\\n",starts:n}],i:"\\S"},c={b:"\\[",e:"\\]",c:[e.inherit(n)],i:"\\S"};return t.splice(t.length,0,a,c),{c:t,k:r,i:"\\S"}}),hljs.registerLanguage("xml",function(e){var r="[A-Za-z0-9\\._:-]+",t={b:/<\?(php)?(?!\w)/,e:/\?>/,sL:"php"},n={eW:!0,i:/,r:0,c:[t,{cN:"attr",b:r,r:0},{b:"=",r:0,c:[{cN:"string",c:[t],v:[{b:/"/,e:/"/},{b:/'/,e:/'/},{b:/[^\s\/>]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xsl","plist"],cI:!0,c:[{cN:"meta",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},e.C("",{r:10}),{b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"",rE:!0,sL:["css","xml"]}},{cN:"tag",b:"",rE:!0,sL:["actionscript","javascript","handlebars","xml"]}},t,{cN:"meta",b:/<\?\w+/,e:/\?>/,r:10},{cN:"tag",b:"?",e:"/?>",c:[{cN:"name",b:/[^\/><\s]+/,r:0},n]}]}}),hljs.registerLanguage("javascript",function(e){return{aliases:["js"],k:{keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},{cN:"meta",b:/^#!/,e:/$/},e.ASM,e.QSM,{cN:"string",b:"`",e:"`",c:[e.BE,{cN:"subst",b:"\\$\\{",e:"\\}"}]},e.CLCM,e.CBCM,{cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{b:/,e:/>\s*[);\]]/,r:0,sL:"xml"}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:/[A-Za-z$_][0-9A-Za-z$_]*/}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:[e.CLCM,e.CBCM]}],i:/\[|%/},{b:/\$[(.]/},{b:"\\."+e.IR,r:0},{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#(?!!)/}}),hljs.registerLanguage("css",function(e){var r="[a-zA-Z-][a-zA-Z0-9_-]*",t={b:/[A-Z\_\.\-]+\s*:/,rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:/\S/,e:":",eE:!0,starts:{eW:!0,eE:!0,c:[{b:/[\w-]+\s*\(/,rB:!0,c:[{cN:"built_in",b:/[\w-]+/}]},e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"number",b:"#[0-9A-Fa-f]+"},{cN:"meta",b:"!important"}]}}]};return{cI:!0,i:/[=\/|'\$]/,c:[e.CBCM,{cN:"selector-id",b:/#[A-Za-z0-9_-]+/},{cN:"selector-class",b:/\.[A-Za-z0-9_-]+/},{cN:"selector-attr",b:/\[/,e:/\]/,i:"$"},{cN:"selector-pseudo",b:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{b:"@",e:"[{;]",c:[{cN:"keyword",b:/\S+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[e.ASM,e.QSM,e.CSSNM]}]},{cN:"selector-tag",b:r,r:0},{b:"{",e:"}",i:/\S/,c:[e.CBCM,t]}]}});
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright {yyyy} {name of copyright owner}
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/models/book.js:
--------------------------------------------------------------------------------
1 | /*
2 | * @license
3 | * Copyright 2018 Cayle Sharrock
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
6 | * with the License. You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
11 | * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12 | * License for the specific language governing permissions and limitations under the License.
13 | *
14 | */
15 |
16 | const Sequelize = require('sequelize');
17 | const sequelize = require('./connection');
18 | const BigNumber = require('bignumber.js');
19 | const JournalEntry = require('./journal');
20 | const Transaction = require('./transaction');
21 | const {ZERO} = require('./types');
22 | const {AleError, codes} = require('../lib/errors');
23 | const Op = Sequelize.Op;
24 |
25 | /**
26 | * A Book contains many journal entries
27 | */
28 | const Book = sequelize.define('book', {
29 | name: {type: Sequelize.TEXT, unique: true},
30 | quoteCurrency: {type: Sequelize.STRING, defaultValue: 'USD'}
31 | });
32 |
33 | JournalEntry.belongsTo(Book, {foreignKey: {allowNull: false}, onDelete: 'CASCADE'});
34 | Transaction.belongsTo(Book, {foreignKey: {allowNull: false}, onDelete: 'CASCADE'}); // Redundant, but saves double JOINS in a few queries
35 | Book.JournalEntries = Book.hasMany(JournalEntry, {foreignKey: {allowNull: false}, onDelete: 'CASCADE'});
36 |
37 | /**
38 | * Creates a new journal entry, but doesn't persist it to the DB until entry.commit() is called
39 | */
40 | Book.prototype.newJournalEntry = function(memo, date = null) {
41 | return JournalEntry.build({
42 | bookId: this.id,
43 | memo: memo,
44 | quoteCurrency: this.quoteCurrency,
45 | timestamp: date || new Date()
46 | });
47 | };
48 |
49 | /**
50 | * Returns journal entries matching the given constraints
51 | * @param query
52 | * @param query.startDate {string|number}
53 | * @param query.endDate {string|number}
54 | * @param query.memo {string}
55 | * @param query.newestFirst {boolean} Order results by desc timestamp, (default : false).
56 | * @return Promise
57 | */
58 | Book.prototype.getJournalEntries = function(query) {
59 | const parsedQuery = parseQuery(this.getDataValue('id'), query);
60 | parsedQuery.order = parsedQuery.order || [['timestamp', 'ASC']];
61 | return JournalEntry.findAll(parsedQuery).then(rows => {
62 | const results = rows.map(r => r.values());
63 | return results;
64 | }).catch(e => {
65 | const err = new AleError(`JournalEntry query failed. ${e.message}`, codes.DatabaseQueryError);
66 | return sequelize.Promise.reject(err);
67 | });
68 | };
69 | /**
70 | * Returns a promise fo the balance of the given account.
71 | * @param query
72 | * @param query.account {string|Array} [acct, subacct, subsubacct]
73 | * @param query.startDate {string|number} Anything parseable by new Date()
74 | * @param query.endDate {string|number} Anything parseable by new Date()
75 | * @param query.perPage {number} Limit results to perPage
76 | * @param query.page {number} Return page number
77 | * @param inQuoteCurrency boolean - whether to convert balance to the quote currency or not (default: false)
78 | * @return {creditTotal, debitTotal, balance, currency, numTransactions}
79 | */
80 | Book.prototype.getBalance = function(query, inQuoteCurrency = false) {
81 | query = parseQuery(this.getDataValue('id'), query);
82 | const credit = inQuoteCurrency ? sequelize.literal('credit * "exchangeRate"') : sequelize.col('credit');
83 | const debit = inQuoteCurrency ? sequelize.literal('debit * "exchangeRate"') : sequelize.col('debit');
84 | query.attributes = [
85 | [sequelize.fn('SUM', credit), 'creditTotal'],
86 | [sequelize.fn('SUM', debit), 'debitTotal'],
87 | [sequelize.fn('COUNT', sequelize.col('id')), 'numTransactions'],
88 | [sequelize.fn('MAX', sequelize.col('currency')), 'currency']
89 | ];
90 | return Transaction.findAll(query).then(result => {
91 | result = result.shift();
92 | if (!result) {
93 | return {
94 | balance: 0,
95 | notes: 0
96 | };
97 | }
98 | const creditTotal = +result.get('creditTotal');
99 | const debitTotal = +result.get('debitTotal');
100 | const total = creditTotal - debitTotal;
101 | return {
102 | creditTotal: creditTotal,
103 | debitTotal: debitTotal,
104 | balance: total,
105 | currency: inQuoteCurrency ? this.quoteCurrency : result.get('currency'),
106 | numTransactions: +result.get('numTransactions')
107 | };
108 | });
109 | };
110 |
111 | /**
112 | * Return all journal entries ordered by time for a given book (subject to the constraints passed in the query)
113 | * @param query
114 | * @param query.startDate {string|number} Anything parseable by new Date()
115 | * @param query.endDate {string|number} Anything parseable by new Date()
116 | * @param query.perPage {number} Limit results to perPage
117 | * @param query.page {number} Return page number
118 | * @param query.newestFirst {boolean} Order results by desc timestamp, (default : false).
119 | * @return {Array} of JournalEntry
120 | */
121 | Book.prototype.getLedger = function(query) {
122 | query = parseQuery(this.get('id'), query);
123 | query.order = query.order || [['timestamp', 'ASC']];
124 | query.include = [ Transaction ];
125 | return JournalEntry.findAll(query);
126 | };
127 |
128 | /**
129 | * Return all transactions ordered by time for a given book (subject to the constraints passed in the query)
130 | * @param query
131 | * @param query.account {string|Array} A single, or array of accounts to match. Assets will match Assets and Assets:*
132 | * @param query.perPage {number} Limit results to perPage
133 | * @param query.page {number} Return page number
134 | * @param query.newestFirst {boolean} Order results by desc timestamp, (default : false).
135 | * @return {Array} of Transaction
136 | */
137 | Book.prototype.getTransactions = function(query) {
138 | query = parseQuery(this.get('id'), query);
139 | query.order = query.order || [['timestamp', 'ASC']];
140 | return Transaction.findAll(query);
141 | };
142 |
143 | Book.prototype.voidEntry = function(journalId, reason) {
144 | return JournalEntry.findById(journalId)
145 | .then(entry => {
146 | if (!entry) {
147 | return sequelize.Promise.reject(new AleError(`Journal entry not found with ID ${journalId}`, codes.TransactionIDNotFound));
148 | }
149 | return entry.voidEntry(this, reason);
150 | });
151 | };
152 |
153 | Book.prototype.listAccounts = function() {
154 | return Transaction.aggregate('account', 'distinct', {
155 | where: {
156 | bookId: this.getDataValue('id')
157 | },
158 | plain: false
159 | }).then(results => {
160 | // Make array
161 | const final = [];
162 | for (let result of results) {
163 | const paths = result.distinct.split(':');
164 | const prev = [];
165 | for (let acct of paths) {
166 | prev.push(acct);
167 | final.push(prev.join(':'));
168 | }
169 | }
170 | return Array.from(new Set(final)); // uniques
171 | });
172 | };
173 |
174 | Book.prototype.markToMarket = function(query, exchangeRates) {
175 | const rates = Book.normalizeRates(this.quoteCurrency, exchangeRates);
176 | if (!rates) {
177 | const err = new AleError('Cannot mark-to-market if no current exchange rates are supplied', codes.MissingInput);
178 | return sequelize.Promise.reject(err);
179 | }
180 | query = parseQuery(this.getDataValue('id'), query);
181 | query.attributes = [
182 | 'account',
183 | 'currency',
184 | [sequelize.fn('SUM', sequelize.literal('credit - debit')), 'balance']
185 | ];
186 | query.group = ['account', 'currency'];
187 | return Transaction.findAll(query).then(txs => {
188 | const result = {};
189 | let profit = ZERO;
190 | txs.forEach(tx => {
191 | if (!rates[tx.currency]) {
192 | throw new AleError(`A ${tx.currency} transaction exists, but its current exchange rate was not provided`, codes.ExchangeRateNotFound);
193 | }
194 | let currentBal = (new BigNumber(tx.get('balance'))).div(rates[tx.currency]);
195 | profit = profit.plus(currentBal);
196 | result[tx.get('account')] = +currentBal;
197 | });
198 | result.unrealizedProfit = +profit;
199 | return result;
200 | }).catch(err => {
201 | return sequelize.Promise.reject(err);
202 | });
203 | };
204 |
205 | Book.normalizeRates = function(currency, rates) {
206 | let base = rates[currency];
207 | if (!base) {
208 | return null;
209 | }
210 | base = new BigNumber(base);
211 | const result = Object.assign({}, rates);
212 | for (let cur in result) {
213 | result[cur] = +(new BigNumber(rates[cur]).div(base));
214 | }
215 | return result;
216 | };
217 |
218 | /**
219 | * Convert object into Sequelise 'where' clause
220 | * @param query {{account: {acct, subacct, subsubacct}, startDate, endDate, perPage, page, memo}}
221 | * @returns {Array} of Book models
222 | */
223 | function parseQuery(id, query) {
224 | let account;
225 | const parsed = {where: {bookId: id}};
226 | query = query || {};
227 | if (query.perPage) {
228 | const perPage = query.perPage || 25;
229 | parsed.offset = ((query.page || 1) - 1) * perPage;
230 | parsed.limit = query.perPage;
231 | delete query.perPage;
232 | delete query.page;
233 | }
234 |
235 | if ((account = query.account)) {
236 |
237 | if (account instanceof Array) {
238 | let accountList = account.map(a => ({[Op.like]: `${a}%`}));
239 | parsed.where.account = {[Op.or]: accountList};
240 | }
241 | else {
242 | parsed.where.account = {[Op.like]: `${account}%`};
243 | }
244 | delete query.account;
245 | }
246 |
247 | if (query.journalEntry) {
248 | parsed.where.journalEntry = query.journalEntry;
249 | }
250 |
251 | if (query.startDate || query.endDate) {
252 | parsed.where.timestamp = {};
253 | }
254 | if (query.startDate) {
255 | parsed.where.timestamp[Op.gte] = new Date(query.startDate);
256 | delete query.startDate;
257 | }
258 | if (query.endDate) {
259 | parsed.where.timestamp[Op.lte] = new Date(query.endDate);
260 | delete query.endDate;
261 | }
262 | if (query.memo) {
263 | parsed.where.memo = {[Op.or]: [query.memo, `${query.memo} [REVERSED]`]};
264 | delete query.memo;
265 | }
266 | if (query.newestFirst) {
267 | parsed.order = [['timestamp', 'DESC']];
268 | }
269 |
270 | return parsed;
271 | }
272 |
273 | Book.listBooks = function() {
274 | return Book.findAll({order: ['name']}).then(results => {
275 | if (!results) {
276 | return [];
277 | }
278 | return results;
279 | });
280 | };
281 |
282 | /**
283 | * Gets an existing book, or creates a new one
284 | * @param name The name of the new book
285 | * @param quoteCurrency The Base currency for the book. If the book already exists, this parameter must match the existing base currency or be undefined.
286 | */
287 | Book.getOrCreateBook = function(name, quoteCurrency) {
288 | return Book.findOrCreate({where: {name: name}, defaults: {quoteCurrency: quoteCurrency}})
289 | .then(result => {
290 | if (!result || result.length != 2) {
291 | return sequelize.Promise.reject(new AleError('Book query failed to return expected result', codes.DatabaseQueryError));
292 | }
293 | const book = result[0];
294 | const cur = book.quoteCurrency;
295 | const isNewBook = result[1];
296 | if (quoteCurrency && (quoteCurrency != cur)) {
297 | const err = new AleError(`Request Base currency does not match existing base currency. Requested: ${quoteCurrency}. Current: ${cur}`, codes.MismatchedCurrency);
298 | return sequelize.Promise.reject(err);
299 | }
300 | return {isNew: isNewBook, book: book};
301 | });
302 | };
303 |
304 | /**
305 | * Gets an existing, or returns an error
306 | * @param name The name of the new book
307 | */
308 | Book.getBook = function(name) {
309 | return Book.findOne({where: {name: name}}).then(book => {
310 | if (!book) {
311 | return sequelize.Promise.reject(new AleError(`Book ${name} does not exist.`, codes.BookDoesNotExist));
312 | }
313 | return book;
314 | }, err => {
315 | return sequelize.Promise.reject(new AleError(`Error getting book info. ${err.message}`, codes.DatabaseQueryError));
316 | });
317 | };
318 |
319 | Book.prototype.values = function() {
320 | return {
321 | id: this.get('id'),
322 | name: this.get('name'),
323 | currency: this.get('quoteCurrency'),
324 | createdAt: this.get('createdAt').valueOf(),
325 | updatedAt: this.get('updatedAt').valueOf()
326 | };
327 | };
328 |
329 | module.exports = Book;
330 |
--------------------------------------------------------------------------------
/web_deploy/swagger.yml:
--------------------------------------------------------------------------------
1 | swagger: '2.0'
2 | schemes:
3 | - http
4 | host: 'localhost:8813'
5 | basePath: /
6 | info:
7 | description: |
8 | # A.L.E Double-Entry Accounting REST API
9 |
10 | ALE divides itself into "books", each of which store *journal entries* and their child *transactions*.
11 | The cardinal rule of double-entry accounting is that "everything must balance out to zero", and that rule is applied
12 | to every journal entry written to the book.
13 | version: 1.0.0
14 | title: A.L.E.
15 | license:
16 | name: Apache 2.0
17 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
18 | x-logo:
19 | url: /ale/logo.png
20 | externalDocs:
21 | description: A.L.E. Github page
22 | url: 'https://github.com/CjS77/ale'
23 | produces:
24 | - application/json
25 | consumes:
26 | - application/json
27 | paths:
28 | /books/:
29 | get:
30 | summary: List all current books
31 | description: |
32 | Produces an array of all the books currently registered on the database.
33 | operationId: getBooks
34 | responses:
35 | '200':
36 | description: The array of books
37 | schema:
38 | $ref: '#/definitions/Books'
39 | examples:
40 | application/json:
41 | - id: General Ledger
42 | currency: USD
43 | - id: London office ledger
44 | currency: GBP
45 | post:
46 | parameters:
47 | - in: body
48 | name: body
49 | description: New Book definition
50 | required: true
51 | schema:
52 | $ref: '#/definitions/Book'
53 | operationId: postBooks
54 | responses:
55 | '200':
56 | description: Success
57 | schema:
58 | $ref: '#/definitions/BookResponse'
59 | examples:
60 | application/json:
61 | success: false
62 | message: Book 'General' already exists
63 | '400':
64 | description: Bad input
65 | schema:
66 | $ref: '#/definitions/ErrorResponse'
67 | examples:
68 | application/json:
69 | success: false
70 | message: Book 'Foobar' does not exist
71 | '/books/{bookId}/ledger':
72 | parameters:
73 | - name: bookId
74 | description: The book to extract entries from
75 | in: path
76 | type: integer
77 | required: true
78 | post:
79 | summary: Post a new Journal entry
80 | description: Add a new (balanced) Journal entry to the book
81 | operationId: postBookEntry
82 | parameters:
83 | - name: body
84 | in: body
85 | description: The new Journal Entry definition
86 | schema:
87 | $ref: '#/definitions/NewJournalEntry'
88 | produces:
89 | - application/json
90 | responses:
91 | '200':
92 | description: Success
93 | schema:
94 | $ref: '#/definitions/PostEntryResponse'
95 | get:
96 | summary: Fetch the ledger
97 | description: |
98 | Fetches all transactions for the given book for the dates provided
99 | operationId: getBookEntries
100 | parameters:
101 | - name: bookId
102 | description: The book to extract entries from
103 | in: path
104 | type: integer
105 | required: true
106 | - name: startDate
107 | in: query
108 | description: The start date for entries
109 | required: false
110 | type: string
111 | - name: endDate
112 | in: query
113 | description: The end date for entries
114 | required: false
115 | type: string
116 | - name: perPage
117 | in: query
118 | description: The number of results per page
119 | required: false
120 | type: integer
121 | - name: page
122 | in: query
123 | description: The page number
124 | required: false
125 | type: integer
126 | produces:
127 | - application/json
128 | responses:
129 | '200':
130 | description: Success
131 | schema:
132 | $ref: '#/definitions/Entries'
133 | examples:
134 | application/json:
135 | book:
136 | id: General Ledger
137 | currency: USD
138 | startDate: 1520228313023
139 | endDate: 1520428313023
140 | entries:
141 | - date: 1520228313023
142 | memo: Payroll
143 | transactions:
144 | - account: 'Bank:Local'
145 | debit: 1000
146 | currency: USD
147 | exchangeRate: 1
148 | - account: 'Employees:Alice'
149 | value: 500
150 | currency: USD
151 | exchangeRate: 1
152 | - account: 'Employees:Bob'
153 | value: 500
154 | currency: USD
155 | exchangeRate: 1
156 | '400':
157 | description: 'Invalid input, such as unknown book'
158 | schema:
159 | $ref: '#/definitions/Response'
160 | '/books/{bookId}/accounts':
161 | parameters:
162 | - name: bookId
163 | description: The book to extract entries from
164 | in: path
165 | type: integer
166 | required: true
167 | get:
168 | summary: List all accounts
169 | operationId: getAccounts
170 | produces:
171 | - application/json
172 | responses:
173 | '200':
174 | description: Success
175 | schema:
176 | type: array
177 | items:
178 | type: string
179 | '/books/{bookId}/transactions':
180 | parameters:
181 | - name: bookId
182 | description: The book to extract entries from
183 | in: path
184 | type: integer
185 | required: true
186 | get:
187 | summary: List all transactions for given accounts
188 | operationId: getTransactions
189 | parameters:
190 | - name: accounts
191 | description: A comma-separated search term for accounts
192 | in: query
193 | type: string
194 | required: true
195 | - name: perPage
196 | in: query
197 | description: The number of results per page
198 | required: false
199 | type: integer
200 | - name: page
201 | in: query
202 | description: The page number
203 | required: false
204 | type: integer
205 | produces:
206 | - application/json
207 | responses:
208 | '200':
209 | description: Success
210 | schema:
211 | type: array
212 | items:
213 | $ref: '#/definitions/Transaction'
214 | '/books/{bookId}/balance':
215 | parameters:
216 | - name: bookId
217 | description: The book to extract entries from
218 | in: path
219 | type: integer
220 | required: true
221 | get:
222 | summary: Return an account balance
223 | operationId: getBalance
224 | parameters:
225 | - name: account
226 | description: The account to get the balance for
227 | in: query
228 | type: string
229 | required: true
230 | - name: inQuoteCurrency
231 | description: 'If true (default), converts all values to the quote currency first'
232 | in: query
233 | type: boolean
234 | required: false
235 | produces:
236 | - application/json
237 | responses:
238 | '200':
239 | description: Success
240 | schema:
241 | $ref: '#/definitions/Balance'
242 | '/books/{bookId}/marktomarket':
243 | parameters:
244 | - name: bookId
245 | description: The book to extract entries from
246 | in: path
247 | type: integer
248 | required: true
249 | post:
250 | summary: Mark the account(s) to market
251 | description: Calculates the unlrealised profit of the given accounts at the exchange rate vector provided
252 | operationId: postMarkToMarket
253 | parameters:
254 | - name: body
255 | in: body
256 | schema:
257 | type: object
258 | required:
259 | - accounts
260 | - exchangeRates
261 | properties:
262 | accounts:
263 | type: array
264 | items:
265 | type: string
266 | exchangeRates:
267 | type: object
268 | additionalProperties:
269 | type: number
270 | produces:
271 | - application/json
272 | responses:
273 | '200':
274 | description: Success
275 | schema:
276 | type: object
277 | items:
278 | $ref: '#/definitions/UnrealisedProfit'
279 | definitions:
280 | Books:
281 | type: array
282 | items:
283 | $ref: '#/definitions/Book'
284 | Book:
285 | type: object
286 | required:
287 | - name
288 | - currency
289 | properties:
290 | id:
291 | description: The id for the book the book
292 | type: integer
293 | name:
294 | description: The name of the book
295 | type: string
296 | example: General Ledger
297 | currency:
298 | description: |
299 | The currency the book is referenced in.
300 | All other currencies and calculations are quoted in terms of this currency
301 | type: string
302 | example: USD
303 | createdAt:
304 | description: The timestamp of when the book was created
305 | type: number
306 | updatedAt:
307 | description: The timestamp of the last time this entry was modified
308 | type: number
309 | Entries:
310 | type: object
311 | properties:
312 | book:
313 | $ref: '#/definitions/Book'
314 | startDate:
315 | type: number
316 | endDate:
317 | type: number
318 | entries:
319 | type: array
320 | items:
321 | $ref: '#/definitions/Entry'
322 | Entry:
323 | type: object
324 | properties:
325 | date:
326 | description: The timestamp for the entry
327 | type: number
328 | memo:
329 | description: The description for the entry
330 | type: string
331 | voided:
332 | description: Indicates whether the entry is still valid
333 | type: boolean
334 | voidReason:
335 | description: The reason for the entry reversal
336 | type: string
337 | transactions:
338 | description: An array of transactions for the entry
339 | type: array
340 | items:
341 | $ref: '#/definitions/Transaction'
342 | Transaction:
343 | type: object
344 | required:
345 | - account
346 | properties:
347 | account:
348 | description: The account this transaction is reflected on
349 | type: string
350 | credit:
351 | description: The credit value of the transaction
352 | type: number
353 | debit:
354 | description: The debit value of the transaction
355 | type: number
356 | currency:
357 | description: The currency for this transaction
358 | type: string
359 | exchangeRate:
360 | description: The exchange rate to convert to the basis currency
361 | type: number
362 | Response:
363 | type: object
364 | properties:
365 | success:
366 | description: Indicates whether request was succesful
367 | type: boolean
368 | message:
369 | type: string
370 | ErrorResponse:
371 | allOf:
372 | - $ref: '#/definitions/Response'
373 | - properties:
374 | errorCode:
375 | type: integer
376 | BookResponse:
377 | allOf:
378 | - $ref: '#/definitions/Response'
379 | - $ref: '#/definitions/Book'
380 | PostEntryResponse:
381 | allOf:
382 | - $ref: '#/definitions/Response'
383 | - properties:
384 | id:
385 | description: The id of the new Journal Entry
386 | type: integer
387 | NewJournalEntry:
388 | type: object
389 | required:
390 | - memo
391 | - transactions
392 | properties:
393 | memo:
394 | description: The Journal Entry description
395 | type: string
396 | timestamp:
397 | description: The time stamp for the journal entry
398 | type: string
399 | transactions:
400 | type: array
401 | items:
402 | $ref: '#/definitions/Transaction'
403 | Balance:
404 | type: object
405 | properties:
406 | creditTotal:
407 | description: The total value of credits into the account
408 | type: number
409 | debitTotal:
410 | description: The total value of debits into the account
411 | type: number
412 | balance:
413 | description: The current account balabce (credits - debits)
414 | type: number
415 | currency:
416 | description: The nominal currency of the account
417 | type: number
418 | numTransactions:
419 | description: The number of transactions into the account
420 | type: number
421 | UnrealisedProfit:
422 | type: object
423 | properties:
424 | unrealizedProfit:
425 | description: The total current unrealised profit for the given accounts
426 | type: number
427 | additionalProperties:
428 | type: number
429 |
--------------------------------------------------------------------------------
/spec/swagger.yaml:
--------------------------------------------------------------------------------
1 | # Documentation: https://swagger.io/docs/specification/2-0/basic-structure/
2 |
3 | swagger: '2.0'
4 | schemes:
5 | - http
6 | host: localhost:8813
7 | basePath: /
8 | info:
9 | description: |
10 | # A.L.E Double-Entry Accounting REST API
11 |
12 | ALE divides itself into "books", each of which store *journal entries* and their child *transactions*.
13 | The cardinal rule of double-entry accounting is that "everything must balance out to zero", and that rule is applied
14 | to every journal entry written to the book.
15 | version: '1.0.0'
16 | title: A.L.E.
17 | license:
18 | name: Apache 2.0
19 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
20 | x-logo:
21 | url: '/ale/logo.png'
22 | externalDocs:
23 | description: A.L.E. Github page
24 | url: 'https://github.com/CjS77/ale'
25 | produces:
26 | # This is a global default. You can OVERWRITE it in each specific operation.
27 | - application/json
28 | consumes:
29 | # List of mime types the API endpoints consume
30 | # This is a global default. You can OVERWRITE it in each specific operation.
31 | - application/json
32 | # Holds the relative paths to the individual endpoints. The path is appended to the basePath in order to construct the full URL.
33 | paths:
34 | '/books/': # path parameter in curly braces
35 | get:
36 | summary: List all current books
37 | description: |
38 | Produces an array of all the books currently registered on the database.
39 | operationId: getBooks
40 | responses:
41 | 200:
42 | description: The array of books
43 | schema: # response schema can be specified for each response
44 | $ref: '#/definitions/Books'
45 | examples:
46 | application/json:
47 | [
48 | { id: "General Ledger", currency: "USD"},
49 | { id: "London office ledger", currency: "GBP"},
50 | ]
51 |
52 | post:
53 | parameters:
54 | - in: body
55 | name: body
56 | description: New Book definition
57 | required: true
58 | schema:
59 | $ref: '#/definitions/Book'
60 | operationId: postBooks
61 | responses:
62 | 200:
63 | description: Success
64 | schema:
65 | $ref: '#/definitions/BookResponse'
66 | examples:
67 | application/json:
68 | success: false
69 | message: Book 'General' already exists
70 | 400:
71 | description: Bad input
72 | schema:
73 | $ref: '#/definitions/ErrorResponse'
74 | examples:
75 | application/json:
76 | success: false
77 | message: Book 'Foobar' does not exist
78 |
79 | '/books/{bookId}/ledger':
80 | parameters:
81 | - name: bookId
82 | description: The book to extract entries from
83 | in: path
84 | type: integer
85 | required: true
86 | post:
87 | summary: Post a new Journal entry
88 | description: Add a new (balanced) Journal entry to the book
89 | operationId: postBookEntry
90 |
91 | parameters:
92 | - name: body
93 | in: body
94 | description: The new Journal Entry definition
95 | schema:
96 | $ref: '#/definitions/NewJournalEntry'
97 |
98 | produces:
99 | - application/json
100 | responses: # list of responses
101 | 200:
102 | description: Success
103 | schema:
104 | $ref: '#/definitions/PostEntryResponse'
105 |
106 | get:
107 | summary: Fetch the ledger
108 | description: |
109 | Fetches all transactions for the given book for the dates provided
110 | operationId: getBookEntries
111 |
112 | parameters:
113 | - name: bookId
114 | description: The book to extract entries from
115 | in: path
116 | type: integer
117 | required: true
118 |
119 | - name: startDate
120 | in: query
121 | description: 'The start date for entries'
122 | required: false
123 | type: string
124 |
125 | - name: endDate
126 | in: query
127 | description: 'The end date for entries'
128 | required: false
129 | type: string
130 |
131 | - name: perPage
132 | in: query
133 | description: 'The number of results per page'
134 | required: false
135 | type: integer
136 |
137 | - name: page
138 | in: query
139 | description: 'The page number'
140 | required: false
141 | type: integer
142 |
143 | produces:
144 | - application/json
145 | responses: # list of responses
146 | 200:
147 | description: Success
148 | schema:
149 | $ref: '#/definitions/Entries'
150 | examples:
151 | application/json:
152 | {
153 | book: { id: "General Ledger", currency: 'USD' },
154 | startDate: 1520228313023,
155 | endDate: 1520428313023,
156 | entries: [
157 | {
158 | date: 1520228313023,
159 | memo: 'Payroll',
160 | transactions: [
161 | { account: "Bank:Local", debit: 1000.0, currency: "USD", exchangeRate: 1.0 },
162 | { account: "Employees:Alice", value: 500.0, currency: "USD", exchangeRate: 1.0 },
163 | { account: "Employees:Bob", value: 500.0, currency: "USD", exchangeRate: 1.0 }
164 | ]
165 | }
166 | ]
167 | }
168 | 400:
169 | description: Invalid input, such as unknown book
170 | schema:
171 | $ref: '#/definitions/Response'
172 |
173 | '/books/{bookId}/accounts':
174 | parameters:
175 | - name: bookId
176 | description: The book to extract entries from
177 | in: path
178 | type: integer
179 | required: true
180 | get:
181 | summary: List all accounts
182 | operationId: getAccounts
183 | produces:
184 | - application/json
185 | responses: # list of responses
186 | 200:
187 | description: Success
188 | schema:
189 | type: array
190 | items:
191 | type: string
192 |
193 | '/books/{bookId}/transactions':
194 | parameters:
195 | - name: bookId
196 | description: The book to extract entries from
197 | in: path
198 | type: integer
199 | required: true
200 | get:
201 | summary: List all transactions for given accounts
202 | operationId: getTransactions
203 | parameters:
204 | - name: accounts
205 | description: A comma-separated search term for accounts
206 | in: query
207 | type: string
208 | required: true
209 |
210 | - name: perPage
211 | in: query
212 | description: 'The number of results per page'
213 | required: false
214 | type: integer
215 |
216 | - name: page
217 | in: query
218 | description: 'The page number'
219 | required: false
220 | type: integer
221 |
222 | produces:
223 | - application/json
224 | responses: # list of responses
225 | 200:
226 | description: Success
227 | schema:
228 | type: array
229 | items:
230 | $ref: '#/definitions/Transaction'
231 |
232 | '/books/{bookId}/balance':
233 | parameters:
234 | - name: bookId
235 | description: The book to extract entries from
236 | in: path
237 | type: integer
238 | required: true
239 | get:
240 | summary: Return an account balance
241 | operationId: getBalance
242 | parameters:
243 | - name: account
244 | description: The account to get the balance for
245 | in: query
246 | type: string
247 | required: true
248 | - name: inQuoteCurrency
249 | description: If true (default), converts all values to the quote currency first
250 | in: query
251 | type: boolean
252 | required: false
253 | produces:
254 | - application/json
255 | responses: # list of responses
256 | 200:
257 | description: Success
258 | schema:
259 | $ref: '#/definitions/Balance'
260 |
261 | '/books/{bookId}/marktomarket':
262 | parameters:
263 | - name: bookId
264 | description: The book to extract entries from
265 | in: path
266 | type: integer
267 | required: true
268 | post:
269 | summary: Mark the account(s) to market
270 | description: Calculates the unlrealised profit of the given accounts at the exchange rate vector provided
271 | operationId: postMarkToMarket
272 | parameters:
273 | - name: body
274 | in: body
275 | schema:
276 | type: object
277 | required:
278 | - accounts
279 | - exchangeRates
280 | properties:
281 | accounts:
282 | type: array
283 | items:
284 | type: string
285 | exchangeRates:
286 | type: object
287 | additionalProperties:
288 | type: number
289 | produces:
290 | - application/json
291 | responses: # list of responses
292 | 200:
293 | description: Success
294 | schema:
295 | type: object
296 | items:
297 | $ref: '#/definitions/UnrealisedProfit'
298 |
299 | # An object to hold data types that can be consumed and produced by operations.
300 | # These data types can be primitives, arrays or models.
301 | definitions:
302 |
303 | Books:
304 | type: array
305 | items:
306 | $ref: '#/definitions/Book'
307 |
308 | Book:
309 | type: object
310 | required:
311 | - name
312 | - currency
313 | properties:
314 | id:
315 | description: The id for the book the book
316 | type: integer
317 | name:
318 | description: The name of the book
319 | type: string
320 | example: General Ledger
321 | currency:
322 | description: |
323 | The currency the book is referenced in.
324 | All other currencies and calculations are quoted in terms of this currency
325 | type: string
326 | example: USD
327 | createdAt:
328 | description: The timestamp of when the book was created
329 | type: number
330 | updatedAt:
331 | description: The timestamp of the last time this entry was modified
332 | type: number
333 |
334 | Entries:
335 | type: object
336 | properties:
337 | book:
338 | $ref: '#/definitions/Book'
339 | startDate:
340 | type: number
341 | endDate:
342 | type: number
343 | entries:
344 | type: array
345 | items:
346 | $ref: '#/definitions/Entry'
347 |
348 | Entry:
349 | type: object
350 | properties:
351 | date:
352 | description: The timestamp for the entry
353 | type: number
354 | memo:
355 | description: The description for the entry
356 | type: string
357 | voided:
358 | description: Indicates whether the entry is still valid
359 | type: boolean
360 | voidReason:
361 | description: The reason for the entry reversal
362 | type: string
363 | transactions:
364 | description: An array of transactions for the entry
365 | type: array
366 | items:
367 | $ref: '#/definitions/Transaction'
368 |
369 | Transaction:
370 | type: object
371 | required:
372 | - account
373 | properties:
374 | account:
375 | description: The account this transaction is reflected on
376 | type: string
377 | credit:
378 | description: The credit value of the transaction
379 | type: number
380 | debit:
381 | description: The debit value of the transaction
382 | type: number
383 | currency:
384 | description: The currency for this transaction
385 | type: string
386 | exchangeRate:
387 | description: The exchange rate to convert to the basis currency
388 | type: number
389 |
390 | Response:
391 | type: object
392 | properties:
393 | success:
394 | description: Indicates whether request was succesful
395 | type: boolean
396 | message:
397 | type: string
398 |
399 | ErrorResponse:
400 | allOf:
401 | - $ref: '#/definitions/Response'
402 | - properties:
403 | errorCode:
404 | type: integer
405 |
406 | BookResponse:
407 | allOf:
408 | - $ref: '#/definitions/Response'
409 | - $ref: '#/definitions/Book'
410 |
411 | PostEntryResponse:
412 | allOf:
413 | - $ref: '#/definitions/Response'
414 | - properties:
415 | id:
416 | description: The id of the new Journal Entry
417 | type: integer
418 |
419 | NewJournalEntry:
420 | type: object
421 | required:
422 | - memo
423 | - transactions
424 | properties:
425 | memo:
426 | description: The Journal Entry description
427 | type: string
428 | timestamp:
429 | description: The time stamp for the journal entry
430 | type: string
431 | transactions:
432 | type: array
433 | items:
434 | $ref: '#/definitions/Transaction'
435 |
436 | Balance:
437 | type: object
438 | properties:
439 | creditTotal:
440 | description: The total value of credits into the account
441 | type: number
442 | debitTotal:
443 | description: The total value of debits into the account
444 | type: number
445 | balance:
446 | description: The current account balabce (credits - debits)
447 | type: number
448 | currency:
449 | description: The nominal currency of the account
450 | type: number
451 | numTransactions:
452 | description: The number of transactions into the account
453 | type: number
454 |
455 | UnrealisedProfit:
456 | type: object
457 | properties:
458 | unrealizedProfit:
459 | description: The total current unrealised profit for the given accounts
460 | type: number
461 | additionalProperties:
462 | type: number
463 |
--------------------------------------------------------------------------------
/web_deploy/swagger-ui/lib/marked.js:
--------------------------------------------------------------------------------
1 | (function(){function e(e){this.tokens=[],this.tokens.links={},this.options=e||a.defaults,this.rules=p.normal,this.options.gfm&&(this.options.tables?this.rules=p.tables:this.rules=p.gfm)}function t(e,t){if(this.options=t||a.defaults,this.links=e,this.rules=u.normal,this.renderer=this.options.renderer||new n,this.renderer.options=this.options,!this.links)throw new Error("Tokens array requires a `links` property.");this.options.gfm?this.options.breaks?this.rules=u.breaks:this.rules=u.gfm:this.options.pedantic&&(this.rules=u.pedantic)}function n(e){this.options=e||{}}function r(e){this.tokens=[],this.token=null,this.options=e||a.defaults,this.options.renderer=this.options.renderer||new n,this.renderer=this.options.renderer,this.renderer.options=this.options}function s(e,t){return e.replace(t?/&/g:/&(?!#?\w+;)/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function i(e){return e.replace(/&([#\w]+);/g,function(e,t){return t=t.toLowerCase(),"colon"===t?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""})}function l(e,t){return e=e.source,t=t||"",function n(r,s){return r?(s=s.source||s,s=s.replace(/(^|[^\[])\^/g,"$1"),e=e.replace(r,s),n):new RegExp(e,t)}}function o(){}function h(e){for(var t,n,r=1;rAn error occured: