├── .codeclimate.yml ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── build.sh ├── composer.json ├── createmariadb.sh ├── createmysqldb.sh ├── docs └── Test-Contract.md ├── doctrine-db.php ├── doctrine-migrations.xml ├── easy-coding-standard.yml ├── examples ├── currency-example.php └── journal-entries-example.php ├── phpunit.travis.xml ├── scripts ├── oqgraph.sql ├── test-user.sql ├── travis.install.mariadb.sh └── travis.install.mysql.sh ├── sqltest.sh ├── src ├── php │ └── SAccounts │ │ ├── Account.php │ │ ├── AccountType.php │ │ ├── Accountant.php │ │ ├── AccountsException.php │ │ ├── Chart.php │ │ ├── ChartDefinition.php │ │ ├── DbException.php │ │ ├── Doctrine │ │ └── Version20180428092520.php │ │ ├── Nominal.php │ │ ├── Transaction │ │ ├── Entries.php │ │ ├── Entry.php │ │ ├── SimpleTransaction.php │ │ └── SplitTransaction.php │ │ ├── Visitor │ │ ├── ChartArray.php │ │ ├── ChartPrinter.php │ │ ├── NodeFinder.php │ │ └── NodeSaver.php │ │ └── Zend │ │ └── ErrorHandler.php ├── sql │ ├── mariadb │ │ ├── build-procs.sql │ │ ├── build-tables.sql │ │ ├── build-triggers.sql │ │ ├── drop-procs.sql │ │ └── drop-tables.sql │ └── mysql │ │ ├── build-procs.sql │ │ ├── build-tables.sql │ │ ├── build-triggers.sql │ │ ├── drop-procs.sql │ │ └── drop-tables.sql ├── xml │ └── personal.xml └── xsd │ └── chart-definition.xsd └── test ├── php ├── .gitignore ├── SAccounts │ ├── AccountTest.php │ ├── AccountTypeTest.php │ ├── AccountantTest.php │ ├── ChartDefinitionTest.php │ ├── ChartTest.php │ ├── Transaction │ │ ├── EntriesTest.php │ │ ├── EntryTest.php │ │ ├── SimpleTransactionTest.php │ │ └── SplitTransactionTest.php │ └── Visitor │ │ ├── ChartArrayTest.php │ │ └── ChartPrinterTest.php └── phpunit.xml └── sql ├── add_account_test.sql ├── add_transaction_test.sql └── delete_account_test.sql /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | # Save as .codeclimate.yml (note leading .) in project root directory 2 | languages: 3 | Ruby: false 4 | JavaScript: false 5 | PHP: true 6 | Python: false 7 | exclude_paths: 8 | - "test/*" 9 | - "docs/*" 10 | - "examples/*" 11 | - "scripts/*" 12 | checks: 13 | argument-count: 14 | enabled: false 15 | file-lines: 16 | enabled: false 17 | method-complexity: 18 | enabled: false 19 | method-count: 20 | enabled: false 21 | method-lines: 22 | enabled: false 23 | return-statements: 24 | enabled: false 25 | similar-code: 26 | enabled: false 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # see http://about.travis-ci.org/docs/user/languages/php/ for more hints 2 | language: php 3 | 4 | before_install: 5 | - sudo apt-get update > /dev/null 6 | - pecl install ds 7 | - composer self-update 8 | 9 | before_script: 10 | - mkdir -p build/logs 11 | - if [[ "$DB" = "mariadb" ]]; then ./scripts/travis.install.mariadb.sh; fi; 12 | - if [[ "$DB" = "mysql" ]]; then ./scripts/travis.install.mysql.sh; fi; 13 | - composer install --no-interaction 14 | 15 | script: 16 | - vendor/phpunit/phpunit/phpunit --configuration ./phpunit.travis.xml test/php 17 | 18 | jobs: 19 | include: 20 | - stage: Test 21 | php: 7.2 22 | env: DB=mariadb 23 | addons: 24 | mariadb: '10.4' 25 | 26 | - stage: Test 27 | php: 7.3 28 | env: DB=mariadb 29 | addons: 30 | mariadb: '10.4' 31 | 32 | - stage: Test 33 | php: 7.4 34 | env: DB=mariadb 35 | addons: 36 | mariadb: '10.4' 37 | 38 | - stage: Test 39 | php: 7.2 40 | env: DB=mysql 41 | 42 | - stage: Test 43 | php: 7.3 44 | env: DB=mysql 45 | 46 | - stage: Test 47 | php: 7.4 48 | env: DB=mysql 49 | 50 | 51 | # configure notifications (email, IRC, campfire etc) 52 | notifications: 53 | email: "info@zf4.biz" 54 | 55 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018 Ashley Kitson, UK 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list 7 | of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this 10 | list of conditions and the following disclaimer in the documentation and/or other 11 | materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors may be 14 | used to endorse or promote products derived from this software without specific 15 | prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY 18 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT 20 | SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 22 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 23 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 25 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 26 | DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chippyash/simple-accounts-3 2 | 3 | ## Quality Assurance 4 | 5 | 6 | ![PHP 7.2](https://img.shields.io/badge/PHP-7.2-blue.svg) 7 | ![PHP 7.3](https://img.shields.io/badge/PHP-7.3-blue.svg) 8 | ![PHP 7.4](https://img.shields.io/badge/PHP-7.4-blue.svg) 9 | ![MariaDb 10.0](https://img.shields.io/badge/MariaDb-10.0-blue.svg) 10 | ![MySql 5.6](https://img.shields.io/badge/MySql-5.6-blue.svg) 11 | [![Build Status](https://travis-ci.org/chippyash/simple-accounts-3.svg?branch=master)](https://travis-ci.org/chippyash/simple-accounts-3) 12 | [![Test Coverage](https://api.codeclimate.com/v1/badges/5811a42ebda892357fba/test_coverage)](https://codeclimate.com/github/chippyash/simple-accounts-3/test_coverage) 13 | [![Maintainability](https://api.codeclimate.com/v1/badges/5811a42ebda892357fba/maintainability)](https://codeclimate.com/github/chippyash/simple-accounts-3/maintainability) 14 | 15 | - Please note that developer support for PHP<7.2 was withdrawn at V2 of this library 16 | - 17 | ## What 18 | 19 | Provides a simple database backed general ledger accounting library that allows for a 20 | quick implementation of double entry book keeping into an application. 21 | 22 | This library replaces [chippyash/simple-accounts](https://github.com/chippyash/simple-accounts) 23 | 24 | This library does not provide sales ledgers, purchase ledgers, or other operational 25 | ledgers. Your application probably has these in one form or another. This provides 26 | the 'central' accounting functionality. 27 | 28 | ## Why 29 | 30 | Whilst full blown accounting systems are available, requiring a massive integration 31 | effort, some applications simply need to be able keep some form of internal account. 32 | This library is the direct descendant of something I wrote for a client many years 33 | ago to keep account of game points earned on a web site. Using the double entry 34 | accounting paradigm allowed the site owner to keep track of who had gathered points, 35 | and in which ways, whilst at the same time seeing what this meant to their business 36 | as game points translated into real world value for the customer by way of discounts 37 | and prizes. 38 | 39 | ## Requirements 40 | 41 | Support in this application is given for 2 flavours of SQL, MariaDb and MySql. 42 | 43 | ### MariaDb 44 | 45 | The MariaDb utilises a high performance plugin that greatly simplifies dealing with 46 | hierarchical data. 47 | 48 | You will need MariaDB >=10 with the [OQGraph plugin](https://mariadb.com/kb/en/library/oqgraph-storage-engine/) 49 | installed. Take a look at the `.travis.yml` build script for how we do this on the 50 | Travis build servers. One 'gotcha' that we know about is the setting of the user 51 | creating the triggers. Depending on your MariaDb setup, you may need to give the database 52 | creation script user 'SUPER' privileges. There seems no rhyme nor reason as to why this 53 | is, but be aware. For MariaDb 10 on the travis servers, it needs setting. See 54 | `scripts/test-user.sql` 55 | 56 | If for some reason you cannot use the OQGraph plugin, please follow the instructions 57 | for MySql. It will work in MariaDb as well. 58 | 59 | ### MySql 60 | 61 | Recognising that not everyone is able to utilise the MariaDb advantage, I have provided 62 | an alternate database implementation for plain MySql that utilises Nested Sets. See 63 | [Mike Hillyer's blog](http://mikehillyer.com/articles/managing-hierarchical-data-in-mysql/) 64 | for the basis of its implementation. 65 | 66 | Please note that this is not as performant as the MariaDb solution. Take a look at the 67 | stored procedure definitions for each DB to see the reason why. If you can, use MariaDb. 68 | If you can get the MySql code more performant, submit a pull request! 69 | 70 | Postgres users should be able to adapt the MySql code to implement the database. I rarely 71 | use it, so no direct support is given in this library. If you want to contribute the 72 | Postgres SQL statements, please feel free to provide a pull request. It would be 73 | welcome. 74 | 75 | No Windows variant support is provided at present. If you want it, please feel free 76 | to make a pull request. The library is developed under Linux. 77 | 78 | ## How 79 | 80 | ### Getting it up and running 81 | 82 | Create a database, let's say 'test'. 83 | 84 | Create a database user, 'test' with password 'test'. (You can run `scripts\test-user.sql` 85 | to do this.) 86 | 87 | Give that user all rights to the test database. (see note above re SUPER privs) 88 | 89 | Now run the create script: 90 | 91 | For MariaDb: 92 | 93 | `./createmariaddb.sh test test test` 94 | 95 | For MySql: 96 | 97 | `./createmysqldb.sh test test test` 98 | 99 | to create the database components. NB - PHP Doctrine Migration users should read the 100 | PHP code basic section to utilise the supplied migrations. 101 | 102 | You can run SQL tests by executing `./sqltest.sh test test test localhost`. 103 | 104 | - copy the `test/php/phpunit.xml` file to `test/php/local-phpunit.xml` and 105 | edit it to ensure that you have the correct database connection parameters 106 | 107 | You can run PHP tests by executing `./build.sh`. This also generates the test contract 108 | in the ./docs directory if you have the [TestDox-Converter](https://github.com/chippyash/Testdox-Converter) 109 | installed. If you don't then it may fail. Inspect the script contents to run the raw 110 | PHPUnit command. 111 | 112 | You can run the `examples/currency-example.php` program to see 113 | how you can convert between floating and integer types. 114 |
115 | cd examples
116 | chmod u+x currency-example.php
117 | ./currency-example.php
118 | 
119 | 
120 | The library and supporting database only handle integers, so if you need float support, 121 | use [Chippyash\Currency](https://github.com/chippyash/currency) or provide your own handlers. 122 | 123 | ### Coding Basics- Terminology 124 | 125 | To keep things concise and avoid confusion, particularly if you are an Accountant and 126 | used to different terminology, here is a definition of terms used in this readme. 127 | 128 | - SA: Simple Accounts, this library 129 | - COA: Chart of Account. This is an entire chart. It has ledgers. A COA is hierarchical 130 | in nature, with a `root` ledger, normally called 'COA'. It generally has 2 main child 131 | ledgers, the balance sheet (BS) and the profit and loss account (P&L). Under these, various 132 | other ledgers will exist. You can find numerous references to COA construction on 133 | t'internet. 134 | - Ledger or Account: Used interchangeably. A line in the COA that holds the balance of 135 | all Journal transactions that have been made on the Ledger. In SA, the guiding principle 136 | is that if you update a child ledger, then it will update its parents all the way to 137 | the root ledger, thus keeping the the entire COA in balance. 138 | - Nominal or Nominal code: In General Ledger (GL), ledgers are often refered to by their 139 | `nominal code` or `nominal`. This is accountant or book keeper short code for a ledger. 140 | Each Ledger in this system has a Nominal Code. From a database point of view, it provides 141 | part of the ledger unique key along with the chart id. By convention, it is a Digit String 142 | and is usually from 4 digits upwards. Nominals are used to group related Ledgers 143 | together. By default this library supports up to a 10 digit Nominal Code. The example 144 | programs only use 4 digits which is more than sufficient for everyday usage. See the 145 | chart of account xml files for examples. 146 | - Journal or Journal Entry or Transaction: A record of a change in the balance on a 147 | Ledger. It comprises two parts, the details of the reason for the entry, and the details 148 | of the changes to each ledger that it effects. A Journal must be balanced. That is 149 | its debit and credit amounts must be equal. The system will bork if they are not. This 150 | is a principle defence for double entry book keeping. 151 | 152 | ### Coding Basics (SQL) 153 | As mentioned elsewhere, the fundamentals of this library lay in the SQL code, which 154 | runs on MariaDb with the OQGraph plugin installed or MySql. 155 | 156 | The SQL API is provided via stored procedures. If you want to provide variants, please 157 | respect the API. 158 | 159 | For MariaDb see: 160 | - the procedure definitions in the [src/sql/mariadb/build-procs](https://github.com/chippyash/simple-accounts-3/blob/master/src/sql/mariadb/build-procs.sql) 161 | - the trigger that maintains the account balances in the [src/sql/mariadb/build-triggers](https://github.com/chippyash/simple-accounts-3/blob/master/src/sql/mariadb/build-triggers.sql) 162 | script. 163 | 164 | Otherwise the SQL is pretty straight forward. Study the OQGraph 165 | docs for an understanding of how it's being used. Magic underneath, but simple to use - my kind of code ;-) 166 | 167 | For MySql see: 168 | - the procedure definitions in the [src/sql/mysql/build-procs](https://github.com/chippyash/simple-accounts-3/blob/master/src/sql/mysql/build-procs.sql) 169 | - the trigger that maintains the account balances in the [src/sql/mysql/build-triggers](https://github.com/chippyash/simple-accounts-3/blob/master/src/sql/mysql/build-triggers.sql) 170 | script. 171 | 172 | One slightly baffling procedure is `sa_fu_add_txn`. In particular the parameters, 173 | - arNominals TEXT, 174 | - arAmounts TEXT, 175 | - arTxnType TEXT 176 | 177 | These require a matching set of comma delimited values, which is the only way of getting an array into SQL.: 178 | - arNominals: the list of nominals to effect 179 | - arAmounts: the list of amounts to use 180 | - arTxnType: lst of dr or cr for each nominal 181 | 182 | The php code deals with this by imploding values into a string before calling the SP: 183 | 184 | See Accountant::writeTransaction() method. 185 | See test/sql/add_transaction_test.sql circa L17 for how the SQL proc is called natively. 186 | 187 | If you are a better SQL Head than me (not hard!), then I'd appreciate any suggestions 188 | for operational efficiency. 189 | 190 | ### Coding Basics (PHP) 191 | 192 | Whilst what follows will give you an introduction to what you can do with the library, 193 | you should always look to the tests to gain further insight. 194 | 195 | #### Changes from previous library versions 196 | ##### Organisations 197 | Unlike the previous version of this library, we don't support the concepts of 198 | organisations. Organisations are outside of the remit of this 199 | library as your implementation of them will differ according to your needs. Instead 200 | you should plan on creating some form of one to many join between your organisations 201 | and any chart of accounts (COA) that they use. The `sa_coa` table can hold an 202 | infinite number of COAs, so it shouldn't be too much of a problem. 203 | 204 | #### Doctrine Migrations 205 | 206 | If you are using Doctrine Migrations and Mariadb, you can take advantage of the supplied migration 207 | files in `src\php\SAccounts\Doctrine`. 208 | 209 | For development of this library you can migrate up the required DB structure into the 210 | test database by navigating to the root of this library and running 211 | 212 | `vendor/bin/doctrine-migrations migrations:migrate --configuration doctrine-migrations.xml --db-configuration doctrine-db.php` 213 | 214 | To migrate down use: 215 | 216 | `vendor/bin/doctrine-migrations migrations:migrate prev --configuration doctrine-migrations.xml --db-configuration doctrine-db.php` 217 | 218 | For production use, either copy the migration files into your own migrations directory, 219 | (files are in src/php/SAccounts/Doctrine), or possible more conveniently by creating 220 | your own migration classes in your existing structure and the extending them from the 221 | supplied migrations. That will keep them in your sequence. 222 | 223 | Be aware that new features may result in additional migrations so if you update this 224 | library to a new feature version, check for new ones. 225 | 226 | ##### Control accounts 227 | Like Organisations, we don't support the concept of control accounts in this library. 228 | They are again an implementation detail between your application and this library, 229 | more usually a configuration issue. So add config linking where you need such functionality. 230 | Another problem was the use of the term. Too many accountants objected to it being 231 | used in its previous incarnation, that it was safer to leave it out. 232 | 233 | #### The Accountant 234 | The Accountant is responsible for the majority of operations that you can carry out 235 | in Simple Accounts and needs to be created before anything else can happen. The 236 | Accountant requires a Zend Db Adapter as a construction parameter. 237 | 238 |
239 | use SAccounts\Accountant;
240 | use Zend\Db\Adapter\Adapter;
241 | 
242 | $accountant = new Accountant(
243 | 	new Adapter(
244 | 		[
245 | 			'driver' => 'Pdo_mysql',
246 | 			'database' => 'test',
247 | 			'username' => 'test',
248 | 			'password' => 'test'		
249 | 		]
250 | 	)
251 | );
252 | 
253 | 254 | #### Creating a new chart of accounts 255 | 256 | You create a new chart of accounts (COA) by supplying a ChartDefinition. The 257 | ChartDefinition is supplied with an XML definition file. An example of a definition 258 | can be found in `src\xml\personal.xml` along with the XSD that is used to validate 259 | any definitions in `src\xsd\chart-definition.xsd`. 260 | 261 |
262 | use SAccounts\ChartDefinition;
263 | 
264 | $definition = new ChartDefinition('src/xml/personal.xml');
265 | 
266 | $chartId = $accountant->createChart('Personal', $definition);
267 | 
268 | 269 | This will create the entries in the `sa_coa` table and return you the id of the 270 | new chart. You will probably want to store this in your own tables so you can 271 | retrieve it later. 272 | 273 | The Accountant is now tied to that COA. To use another COA you will 274 | need to create another Accountant. To create the Accountant and tell it to use an 275 | existing COA, simply give the chart id as the second parameter 276 | when constructing the Accountant. 277 | 278 | Please note that you never have to explicitly save the COA. It is done transactionally 279 | by the Accountant when you carry out operations with it. 280 | 281 | #### Accountant operations 282 | 283 | Most operations on the COA are carried out via the Accountant. 284 | 285 | Operations on the COA invariably require you to give the Nominal code for the Account 286 | which is to say, the Account identifier. Whilst in the database primary integer 287 | ids are used, externally we operate using the Nominal code. 288 | 289 | ##### Adding an Account ledger to the COA 290 |
291 | use SAccounts\Nominal;
292 | use SAccounts\Account;
293 | 
294 | $nominal = new Nominal('7700');
295 | $prntNominal = new Nominal(('7000'));
296 | 
297 | $accountant->addAccount(
298 | 	$nominal,  		//nominal code
299 | 	AccountType::EXPENSE(), //account type
300 | 	new StringType('foo'),	//account name
301 | 	$prntNominal		//parent account nominal code (or null)
302 | 	);
303 | 
304 | 305 | The parent Nominal must exist already with one exception. In a brand new COA you can 306 | add the root Account and leave out the parent Nominal parameter. For a root Account 307 | the AccountType must be AccountType::REAL(). Trying to add a second 308 | root Account will throw an exception. 309 | 310 | The AccountType is important and must be appropriate for the Account you are adding. 311 | It controls how the balance on the Account is derived. It also allows you to display 312 | appropriate labels for the debit and credit values on an account. Take a look at the 313 | `src\xml\personal.xml` file for an example of how AccountTypes are used. 314 | 315 | Under typical circumstances, the root account is a REAL account, and has two children, 316 | the Balance Sheet (type DR) and the Profit & Loss (type CR). All other accounts are 317 | children of these two. There is however nothing stopping you configuring your chart 318 | as you wish. 319 | 320 | All DR (debit) type accounts derive their balance as dr amount - cr amount. All CR (credit) 321 | type accounts derive their baslance as cr amount - dr amount. REAL accounts derive their 322 | balance as abs(cr amount - dr amount), which is the same as abs(dr amount - cr amount) 323 | and should usually equal zero. 324 | 325 | ##### Deleting an Account ledger from the COA 326 | 327 | You can delete an Account ledger only if its balance is zero. Attempting to delete 328 | a non zero ledger will throw an exception. NB. Deleting a ledger will delete all of 329 | its child ledgers as well. 330 | 331 | By zero, it is meant that both the debit and credit account values == zero. You cannot 332 | therefore delete account ledgers for which any transactions have been made for it or 333 | any child accounts. This is a simple security measure to ensure that data is not lost. 334 | 335 |
336 | $accountant->delAccount(new Nominal('7000'));
337 | 
338 | 339 | #### Fetching the COA 340 | 341 | Having created the COA or instantiated the Accountant with the chart id, you can fetch 342 | the COA simply with: 343 | 344 |
345 | use SAccounts\Chart;
346 | 
347 | /* @var Chart $chart */
348 | $chart = $accountant->fetchChart(); 
349 | 
350 | 351 | #### Operations on the COA 352 | 353 | ##### Basic COA operations 354 | 355 |
356 | //get an Account from the Chart
357 | /* @var Account $acount */
358 | $account = $chart->getAccount(new Nominal('2000'));
359 | 
360 | //get the parent account of an Account
361 | $account = $chart->getAccount($chart->getParentId(new Nominal('2000')))
362 | //or
363 | $subAccount = $chart->getAccount(new Nominal('2000'));
364 | $prntAccount = $chart->getAccount($chart->getParentId($account->getNominal()));
365 | 
366 | //testing if an account exists in the COA
367 | //returns true or false
368 | $exists = $chart->hasAccount(new Nominal('3000'));
369 | 
370 | //get the name of the COA
371 | /* @var StringType $name */
372 | $name = $chart->getName();
373 | 
374 | //get a ledger's values
375 | $account = $chart->getAccount(new Nominal('2000'));
376 | /* @var IntType $dr */
377 | $dr = $account->dr();
378 | /* @var IntType $cr */
379 | $cr = $account->cr();
380 | 
381 | //NB getting the balance of the root COA account should return zero.  If not
382 | //then your accounts are out of balance and need investigating. Perhaps something
383 | //outside of SA made an update, or a database glitch occurred.
384 | /* @var IntType $balance */
385 | $balance = $account->getBalance();
386 | 
387 | /* @var Nominal $nom */
388 | $nom = $account->getNominal();
389 | /* @var StringType $name */
390 | $name = $account->getName();
391 | /* @var AccountType $type */
392 | $type = $account->getType();
393 | 
394 | 
395 | 396 | ##### The COA as a Tree 397 | 398 | Under the covers, the chart is kept as a [nicmart/Tree](https://github.com/nicmart/Tree) 399 | which I do recommend to you if you need to carry out Tree operations. Gaining access 400 | to it is useful for a variety of tasks, such as displaying a trial balance. For this 401 | we use tree Visitors. You can see a full working example of this in the 402 | `examples\currency-example.php` script. The line of interest is: 403 | 404 |
405 | $accountant->fetchChart()->getTree()->accept(new ChartPrinter(Crcy::create($crcyCd)));
406 | 
407 | 408 | Two end user Visitors are supplied: 409 | - `SAccounts\Visitor\ChartPrinter` which prints the COA to the console 410 | - `SAccounts\Visitor\ChartArray` which returns the COA as an array of values and 411 | account balances 412 | 413 | Other Visitors are used internally in the Chart, but they should all give you a firm 414 | grasp on how to create your own if you need to. 415 | 416 | #### Journal Entries 417 | 418 | ##### Creating Entries 419 | 420 | You create Journal entries in your accounts by adding Transactions to the system. A 421 | Transaction is made up of two parts, the Journal description and a list of transaction 422 | entries, one for each account that is effected by the transaction. Those transactions 423 | have a debit or credit amount. The sum of all debits must equal the sum of all 424 | credits so that the transaction balances in order for the Transaction to be accepted by 425 | the system. 426 | 427 | The basic Transaction type is the SplitTransaction: 428 | 429 |
430 |     /**
431 |      * Constructor
432 |      *
433 |      * @param string $note Defaults to '' if not set
434 |      * @param string $src  user defined source of transaction
435 |      * @param int $ref user defined reference for transaction
436 |      * @param \DateTime $date Defaults to today if not set
437 |      */
438 |     public function __construct(
439 |         string $note = null,
440 |         string $src = null,
441 |         int $ref = null,
442 |         \DateTime $date = null
443 |     )
444 | 
445 | 446 | After construction, you add transaction entries by passing an Entry object to the addEntry() 447 | method. 448 | 449 |
450 | use SAccounts\Transaction\SplitTransaction;
451 | use SAccounts\Transaction\Entry;
452 | 
453 | $amount = new IntType(100);
454 | $txn = (new SplitTransaction())
455 |      ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR()))
456 |      ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR()))
457 | 
458 | 459 | Here, we added two entries for same amount to two accounts, but with the transaction 460 | type being debit for one and credit for the other. You can check that the 461 | transaction is balanced with the `checkBalance()` method which will return true 462 | if the transaction is balanced or false otherwise. 463 | 464 | Whilst the SplitTransaction is useful for adding transactions that comprise of many 465 | entries (e.g. a sale comprises of sale account, vat account and a bank account entries), 466 | for a simple two account entry(like a transfer between bank accounts) you can use the 467 | SimpleTransaction, which is a child of SplitTransaction. 468 | 469 |
470 |     /**
471 |      * Constructor
472 |      *
473 |      * @param Nominal $drAc Account to debit
474 |      * @param Nominal $crAc Account to credit
475 |      * @param int $amount Transaction amount
476 |      * @param string $note Defaults to '' if not set
477 |      * @param int $ref Defaults to 0 if not set
478 |      * @param \DateTime $date Defaults to today if not set
479 |      */
480 |     public function __construct(
481 |         Nominal $drAc,
482 |         Nominal $crAc,
483 |         int $amount,
484 |         ?string $note = null,
485 |         ?string $src = null,
486 |         ?int $ref = null,
487 |         ?\DateTime $date = null
488 |     )
489 | 
490 | 491 | Thus: 492 | 493 |
494 | use SAccounts\Transaction\SimpleTransaction;
495 | 
496 | $txn = new SimpleTransaction(new Nominal('0000'), new Nominal('1000'), 1226);
497 | 
498 | 499 | Having created your transaction by whatever means, you can then add it to the accounts 500 | with: 501 | 502 |
503 | $txnId = $accountant->writeTransaction($txn);
504 | 
505 | 506 | Writing a transaction automatically updates the COA ledger balances. 507 | 508 | ##### Retrieving Entries 509 | 510 | You retrieve a single transaction from the accounts with 511 | 512 |
513 | /* @var SplitTransaction $txn */
514 | $txn = $accountant->fetchTransaction(102);
515 | 
516 | 517 | To retrieve all entries for an account ledger: 518 |
519 | /** @var Ds\Set $transactions */
520 | $transactions = $accountant->fetchAccountJournals(new Nominal('2000'));
521 | 
522 | 523 | The Set contains SplitTransactions with Entries reflecting only the side of the 524 | double entry that the transaction acted up for that journal entry. You can see an 525 | example of how this works in `examples/journal-entries-example.php`. 526 | 527 | ## Notes 528 | 529 | The library is built to rely on pure SQL in the database. Whilst I'm providing 530 | a PHP API layer to it, you can use the underlaying SQL from any language. If you 531 | are a Python, Java or other developer, please feel free to add your own 532 | language API under the `src` directory 533 | 534 | My references here apply to the fact that I develop primarily in PHP. If they don't 535 | apply to your dev language of choice, ignore them. 536 | 537 | Finally, if in doubt, read the source code. It's well documented. 538 | 539 | ## Further documentation 540 | 541 | [Test Contract](https://github.com/chippyash/simple-accounts-3/blob/master/docs/Test-Contract.md) in the docs directory. 542 | 543 | This library makes use of the Standard [PHP DS extension](https://www.php.net/manual/en/book.ds.php) 544 | 545 | It also employs a great deal of functional programming derived from the [Monad](https://github.com/chippyash/monad) 546 | and [Assembly](https://github.com/chippyash/Assembly-Builder) libraries. 547 | 548 | If you are unfamiliar with them, please take a moment to study them. 549 | 550 | As the library only deals with integers as values (to ensure numerical accuracy) you 551 | may want to have a look at the [Currency](https://github.com/chippyash/currency) library 552 | that is used in the example scripts. 553 | 554 | Check out [ZF4 Packages](http://zf4.biz/packages?utm_source=github&utm_medium=web&utm_campaign=blinks&utm_content=simpleaccounts3) for more packages 555 | 556 | ## Changing the library 557 | 558 | 1. fork it 559 | 2. write the test 560 | 3. amend it 561 | 4. do a pull request 562 | 563 | Found a bug you can't figure out? 564 | 565 | 1. fork it 566 | 2. write the test 567 | 3. do a pull request 568 | 569 | NB. Make sure you rebase to HEAD before your pull request 570 | 571 | Or - raise an issue ticket. 572 | 573 | ## Where? 574 | 575 | The library is hosted at [Github](https://github.com/chippyash/simple-accounts-3). It is 576 | available at [Packagist.org](https://packagist.org/packages/chippyash/simple-accounts-3) 577 | 578 | ### Installation (PHP) 579 | 580 | Install [Composer](https://getcomposer.org/) 581 | 582 | #### For production 583 | 584 |
585 |     "chippyash/simple-accounts-3": "~2.0"
586 | 
587 | 588 | `composer install --no-dev` 589 | 590 | #### For development 591 | 592 | Clone this repo, and then run Composer in local repo root to pull in dependencies 593 | 594 |
595 |     git clone git@github.com:chippyash/simple-accounts-3.git simple-accounts
596 |     cd simple-accounts
597 |     composer update
598 | 
599 | 600 | - copy the `test/php/phpunit.xml` file to `test/php/local-phpunit.xml` and 601 | edit it to ensure that you have the correct database connection parameters 602 | 603 | - To run the tests: `composer test:run` 604 | - To lint the code: `composer lint:run` 605 | - To fix lint issues: `composer lint:fix` 606 | 607 | ## License 608 | 609 | This software library is released under the [BSD 3 Clause license](https://opensource.org/licenses/BSD-3-Clause) 610 | 611 | This software library is Copyright (c) 2017-2020, Ashley Kitson, UK 612 | 613 | ## History 614 | 615 | V1.0.0 First production release 616 | 617 | V1.0.1 Documentation for first release 618 | 619 | V1.1.0 Add PHP Doctrine Migrations 620 | 621 | V1.1.1 Change namespace for migrations 622 | 623 | V1.1.2 Support PHP 7.0 & 7.1 624 | 625 | V1.1.3 Support PHP 7.2 626 | 627 | V1.2.0 Add ability to retrieve all transactions for an account 628 | 629 | V1.2.1 docs licenses update 630 | 631 | V1.3.0 Support for MySql 632 | 633 | V1.3.1 File path amend for mariadb migration 634 | 635 | V1.3.2 Remove ambiguity for treatment of real account balances 636 | 637 | V1.4.0 Change of license from GPL V3 to BSD 3 Clause 638 | 639 | V1.4.1 Fix issue 1 640 | 641 | V2.0.0 BC Break. PHP<7.2 support withdrawn. Remove dependency on chippyash/strongtype. 642 | 643 | V2.0.1 Remove dependency on chippyash/identity -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd ~/Projects/chippyash/source/simple-accounts-3/ 3 | vendor/phpunit/phpunit/phpunit -c test/php/local-phpunit.xml --testdox-html contract.html test/php 4 | tdconv -t "Simple Accounts V3" contract.html docs/Test-Contract.md 5 | rm contract.html 6 | 7 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chippyash/simple-accounts-3", 3 | "description": "Database backed Simple Double Entry Accounting V3", 4 | "license": "BSD-3-Clause", 5 | "homepage" : "http://zf4.biz/packages?utm_source=packagist&utm_medium=web&utm_campaign=blinks&utm_content=accounts-2", 6 | "keywords": ["accounting","double entry","database","zend", "mysql", "mariadb", "oqgraph"], 7 | "authors": [ 8 | { 9 | "name": "Ashley Kitson", 10 | "email": "info@zf4.biz" 11 | } 12 | ], 13 | 14 | "minimum-stability": "stable", 15 | 16 | "require": { 17 | "php": ">=7.2", 18 | "ext-pdo": "*", 19 | "ext-ds": "*", 20 | "zendframework/zend-db": ">=2.9.2,<3", 21 | "chippyash/monad": ">=2,<3", 22 | "chippyash/assembly-builder": ">=2,<3", 23 | "myclabs/php-enum": ">=1.3.2,<2", 24 | "nicmart/tree": ">=0.2.5,<1" 25 | }, 26 | 27 | "require-dev": { 28 | "phpunit/phpunit": "~8.5", 29 | "doctrine/migrations": "~1.5.0|~1.6", 30 | "mikey179/vfsstream": ">=1.6.5,<2", 31 | "chippyash/currency": ">=5.0.2", 32 | "php-ds/php-ds": "~1.3.0", 33 | "symplify/easy-coding-standard": "^6.1", 34 | "ext-dom": "*" 35 | }, 36 | 37 | "suggest": { 38 | "chippyash/currency" : "To turn account values into real currency values", 39 | "doctrine/migrations": "To assist in database implementation for PHP" 40 | }, 41 | 42 | "autoload": { 43 | "psr-4": { 44 | "SAccounts\\": "src/php/SAccounts" 45 | } 46 | }, 47 | "scripts": { 48 | "lint:run": "vendor/bin/ecs check src", 49 | "lint:fix": "vendor/bin/ecs check --fix src", 50 | "test:run": "vendor/bin/phpunit -c test/php/local-phpunit.xml test/" 51 | }, 52 | "scripts-descriptions": { 53 | "lint:run": "Run code linter and look for problems (suggestion: set up your IDE to do this)", 54 | "lint:fix": "Run linter and fix (automatically) any issues if possible", 55 | "test:run": "Run PHP unit tests and display coverage (suggestion: set up your IDE to do this)" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /createmariadb.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Create MariaDb database 3 | # Requires OQGraph support 4 | # 5 | # usage createmariadb.sh dbname dbuid dbpwd 6 | # 7 | # dbname must exist 8 | # dbuid must have privileges to run DDL on database; 9 | 10 | DBNAME=$1 11 | DBUID=$2 12 | DBPWD=$3 13 | DBHOST=$4 14 | 15 | echo "building tables" 16 | cat ./src/sql/mariadb/build-tables.sql | mysql -h $DBHOST -u $DBUID -p$DBPWD $DBNAME 17 | echo "building procs and functions" 18 | cat ./src/sql/mariadb/build-procs.sql | mysql -h $DBHOST -u $DBUID -p$DBPWD $DBNAME 19 | echo "building triggers" 20 | cat ./src/sql/mariadb/build-triggers.sql | mysql -h $DBHOST -u $DBUID -p$DBPWD $DBNAME 21 | -------------------------------------------------------------------------------- /createmysqldb.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Create MySql database 3 | # 4 | # usage createmysqldb.sh dbname dbuid dbpwd 5 | # 6 | # dbname must exist 7 | # dbuid must have privileges to run DDL on database; 8 | 9 | DBNAME=$1 10 | DBUID=$2 11 | DBPWD=$3 12 | DBHOST=$4 13 | 14 | echo "building tables" 15 | cat ./src/sql/mysql/build-tables.sql | mysql -h $DBHOST -u $DBUID -p$DBPWD $DBNAME 16 | echo "building procs and functions" 17 | cat ./src/sql/mysql/build-procs.sql | mysql -h $DBHOST -u $DBUID -p$DBPWD $DBNAME 18 | echo "building triggers" 19 | cat ./src/sql/mysql/build-triggers.sql | mysql -h $DBHOST -u $DBUID -p$DBPWD $DBNAME 20 | -------------------------------------------------------------------------------- /docs/Test-Contract.md: -------------------------------------------------------------------------------- 1 | # Simple Accounts V3 2 | 3 | ## SAccounts\Account 4 | 5 | * ✓ You can create any valid account type 6 | * ✓ You can get a balance for account types that support it 7 | * ✓ Getting balance of a dummy account type will throw an exception 8 | * ✓ You can get the account nominal code 9 | * ✓ You can get the account type 10 | * ✓ You can get the account name 11 | * ✓ You can get the debit and credit amounts 12 | 13 | ## SAccounts\AccountType 14 | 15 | * ✓ Can get values as constants 16 | * ✓ Can get values as classes using static methods 17 | * ✓ Can get a debit column title for a valid account type 18 | * ✓ Get a debit column title with invalid account type will throw exception 19 | * ✓ Get a credit column title with invalid account type will throw exception 20 | * ✓ Can get a credit column title for a valid account type 21 | * ✓ Will get correct balance for all valid account types 22 | * ✓ Get a balance with invalid account type will throw exception 23 | 24 | ## SAccounts\Accountant 25 | 26 | * ✓ An accountant can create a new chart of accounts 27 | * ✓ An accountant can fetch a chart 28 | * ✓ Fetching a chart when chart id is not set will throw an exception 29 | * ✓ You can write a transaction to a journal and update a chart 30 | * ✓ Writing a transaction when chart id is not set will throw an exception 31 | * ✓ You can fetch a journal transaction by its id 32 | * ✓ You can add an account to a chart 33 | * ✓ Adding an account to a non existent parent will throw an exception 34 | * ✓ Trying to add a second root account will throw an exception 35 | * ✓ You can delete a zero balance account 36 | * ✓ Deleting a non zero balance account will throw an exception 37 | * ✓ You can fetch journal entries for an account 38 | * ✓ Fetching journal entries returns a set of split transactions 39 | * ✓ Fetching journal entries for an aggregate account will return an empty set 40 | 41 | ## SAccounts\ChartDefinition 42 | 43 | * ✓ Can construct with valid file name 44 | * ✓ Construction with invalid file name will throw exception 45 | * ✓ Construction with valid file name will return class 46 | * ✓ Getting the definition will throw exception if definition file is invalid xml 47 | * ✓ Getting definition will throw exception if definition fails validation 48 | * ✓ Getting the definition will return a dom document with valid definition file 49 | 50 | ## SAccounts\Chart 51 | 52 | * ✓ Construction creates chart 53 | * ✓ You can give a chart an optional tree in construction 54 | * ✓ You can get an account if it exists 55 | * ✓ Trying to get a non existent account will throw an exception 56 | * ✓ You can test if a chart has an account 57 | * ✓ Trying to get a parent id of a non existent account will throw an exception 58 | * ✓ Getting the parent id of an account that has a parent will return the parent nominal 59 | * ✓ You can provide an optional internal id when constructing a chart 60 | * ✓ You can set the chart root node 61 | 62 | ## SAccounts\Transaction\Entries 63 | 64 | * ✓ You can create an empty entries collection 65 | * ✓ You can create an entries collections with entry values 66 | * ✓ You cannot create an entries collection with non entry values 67 | * ✓ You can add another entry to entries and get new entries collection 68 | * ✓ Check balance will return true if entries are balanced 69 | * ✓ Check balance will return false if entries are not balanced 70 | 71 | ## SAccounts\Transaction\Entry 72 | 73 | * ✓ An entry requires an id an amount and a type 74 | * ✓ An entry must have cr or dr type 75 | * ✓ Constructing an entry with invalid type will throw exception 76 | * ✓ You can get the id of an entry 77 | * ✓ You can get the amount of an entry 78 | * ✓ You can get the type of an entry 79 | 80 | ## Chippyash\Test\SAccounts\Transaction\SimpleTransaction 81 | 82 | * ✓ Basic construction sets an empty note on the transaction 83 | * ✓ Basic construction sets date for today on the transaction 84 | * ✓ You can set an optional note on construction 85 | * ✓ You can set an optional source on construction 86 | * ✓ You can set an optional reference on construction 87 | * ✓ You can set an optional date on construction 88 | * ✓ Constructing a transaction does not set its id 89 | * ✓ You can set and get an id 90 | * ✓ You can get the debit account code 91 | * ✓ You can get the credit account code 92 | * ✓ You can get the transaction amount 93 | * ✓ You can get the transaction note 94 | * ✓ You can get the transaction datetime 95 | 96 | ## SAccounts\Transaction\SplitTransaction 97 | 98 | * ✓ Basic construction sets an empty note on the transaction 99 | * ✓ Basic construction sets date for today on the transaction 100 | * ✓ You can set an optional note on construction 101 | * ✓ A null note will be retrieved as an empty string 102 | * ✓ You can set an optional source on construction 103 | * ✓ A null source will be retrieved as an empty string 104 | * ✓ You can set an optional reference on construction 105 | * ✓ A null reference will be retrieved as a zero integer 106 | * ✓ You can set an optional date on construction 107 | * ✓ Constructing a split transaction does not set its id 108 | * ✓ You can set and get an id 109 | * ✓ Getting the debit account for a split transaction will return an array of nominals 110 | * ✓ Getting the credit account for a split transaction will return an array of nominals 111 | * ✓ Checking if a split transaction is balanced will return true if balanced 112 | * ✓ Checking if a split transaction is balanced will return false if not balanced 113 | * ✓ You can get the total transaction amount if the transaction is balanced 114 | * ✓ If the transaction is not balanced getting the total transaction amount will throw an exception 115 | * ✓ You can get the transaction note 116 | * ✓ You can get the transaction datetime 117 | * ✓ A split transaction is simple if it has one dr and one cr entry 118 | * ✓ You can get an entry by its nominal id 119 | * ✓ Getting an unknown entry will throw an exception 120 | 121 | ## SAccounts\Visitor\ChartArray 122 | 123 | * ✓ Constructing with no currency will return integer values 124 | * ✓ Constructing with a currency will return floats dependent on the currency precision 125 | 126 | ## SAccounts\Visitor\ChartPrinter 127 | 128 | * ✓ The output is sent to the console 129 | * ✓ Output is formatted using the currency symbol 130 | 131 | 132 | Generated by [chippyash/testdox-converter](https://github.com/chippyash/Testdox-Converter) -------------------------------------------------------------------------------- /doctrine-db.php: -------------------------------------------------------------------------------- 1 | 'test', 12 | 'user' => 'test', 13 | 'password' => 'test', 14 | 'host' => 'localhost', 15 | 'driver' => 'pdo_mysql', 16 | ); -------------------------------------------------------------------------------- /doctrine-migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | Doctrine Migrations for Simple Accounts 3 8 | 9 | SAccounts\Doctrine 10 | 11 | 12 | 13 | /src/php/SAccounts/Doctrine 14 | 15 | -------------------------------------------------------------------------------- /easy-coding-standard.yml: -------------------------------------------------------------------------------- 1 | # easy-coding-standard.yml 2 | imports: 3 | - { resource: '%vendor_dir%/symplify/easy-coding-standard/config/set/clean-code.yaml' } 4 | - { resource: '%vendor_dir%/symplify/easy-coding-standard/config/set/psr12.yaml' } 5 | - { resource: '%vendor_dir%/symplify/easy-coding-standard/config/set/php71.yaml' } 6 | 7 | services: 8 | PHP_CodeSniffer\Standards\Generic\Sniffs\Arrays\DisallowLongArraySyntaxSniff: ~ 9 | Symplify\CodingStandard\Sniffs\DeadCode\UnusedPublicMethodSniff: ~ 10 | SlevomatCodingStandard\Sniffs\Commenting\UselessInheritDocCommentSniff: ~ 11 | Symplify\CodingStandard\Fixer\Commenting\RemoveEmptyDocBlockFixer: ~ 12 | 13 | parameters: 14 | indentation: " " 15 | skip: 16 | Symplify\CodingStandard\Sniffs\DeadCode\UnusedPublicMethodSniff: 17 | - src/php/SAccounts/Zend/ErrorHandler.php 18 | - src/php/SAccounts/Zend/ErrorHandler.php 19 | - src/php/SAccounts/Transaction/SplitTransaction.php 20 | - src/php/SAccounts/Accountant.php 21 | - src/php/SAccounts/Transaction/Entry.php 22 | - src/php/SAccounts/ChartDefinition.php 23 | 24 | -------------------------------------------------------------------------------- /examples/currency-example.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 'Pdo_mysql', 46 | 'database' => 'test', 47 | 'username' => 'test', 48 | 'password' => 'test' 49 | ] 50 | ); 51 | 52 | //clear down the test data 53 | $adapter->query('delete from sa_coa', Adapter::QUERY_MODE_EXECUTE); 54 | 55 | $accountant = new Accountant($adapter); 56 | 57 | //create a new chart of accounts 58 | $definition = new ChartDefinition(new StringType(dirname(__DIR__) . '/src/xml/personal.xml')); 59 | $chartId = $accountant->createChart(new StringType('Personal'), $definition); 60 | 61 | //we'll be working with these accounts 62 | $bankAc = new Nominal('1210'); 63 | $savingsAc = new Nominal('1220'); 64 | $salaryAc = new Nominal('4100'); 65 | $foodAc = new Nominal('6400'); 66 | 67 | //let's pay our salary into the bank 68 | /** @var Currency $salary */ 69 | $salary = Crcy::create($crcyCd, 4203.45); 70 | $accountant->writeTransaction( 71 | new SimpleTransaction($bankAc, $salaryAc, $salary->getValue(), new StringType('Jan salary')), 72 | new DateTime('2018-01-29') 73 | ); 74 | echo "Pay salary of {$salary->display()} into Bank\n"; 75 | 76 | //and spend some on food 77 | /** @var Currency $food */ 78 | $food = Crcy::create($crcyCd, 120.16); 79 | $accountant->writeTransaction( 80 | new SimpleTransaction($foodAc, $bankAc, $food->getValue(), new StringType('weekly food shop')), 81 | new DateTime('2018-01-29') 82 | ); 83 | echo "Spend {$food->display()} on food\n"; 84 | 85 | //and save some money for a rainy day 86 | /** @var Currency $savings */ 87 | $savings = Crcy::create($crcyCd, 500); 88 | $accountant->writeTransaction( 89 | new SimpleTransaction($savingsAc, $bankAc, $savings->getValue(), new StringType('rainy day')), 90 | new DateTime('2018-01-29') 91 | ); 92 | echo "Save {$savings->display()} for a rainy day\n\n"; 93 | 94 | //Print out the chart using the console ChartPrinter 95 | $accountant->fetchChart()->getTree()->accept(new ChartPrinter(Crcy::create($crcyCd))); 96 | 97 | echo "\nGo look at the database journal tables for their entries\n"; 98 | -------------------------------------------------------------------------------- /examples/journal-entries-example.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 'Pdo_mysql', 45 | 'database' => 'test', 46 | 'username' => 'test', 47 | 'password' => 'test' 48 | ] 49 | ); 50 | 51 | //clear down the test data 52 | $adapter->query('delete from sa_coa', Adapter::QUERY_MODE_EXECUTE); 53 | 54 | $accountant = new Accountant($adapter); 55 | 56 | //create a new chart of accounts 57 | $definition = new ChartDefinition(new StringType(dirname(__DIR__) . '/src/xml/personal.xml')); 58 | $chartId = $accountant->createChart(new StringType('Personal'), $definition); 59 | 60 | //we'll be working with these accounts 61 | $bankAc = new Nominal('1210'); 62 | $savingsAc = new Nominal('1220'); 63 | $salaryAc = new Nominal('4100'); 64 | $foodAc = new Nominal('6400'); 65 | 66 | //let's pay our salary into the bank 67 | /** @var Currency $salary */ 68 | $salary = Crcy::create($crcyCd, 4203.45); 69 | $accountant->writeTransaction( 70 | new SimpleTransaction($bankAc, $salaryAc, $salary, new StringType('Jan salary')), 71 | new DateTime('2018-01-29') 72 | ); 73 | echo "Pay salary of {$salary->display()} into Bank\n"; 74 | 75 | //and spend some on food 76 | /** @var Currency $food */ 77 | $food = Crcy::create($crcyCd, 120.16); 78 | $accountant->writeTransaction( 79 | new SimpleTransaction($foodAc, $bankAc, $food, new StringType('weekly food shop')), 80 | new DateTime('2018-01-29') 81 | ); 82 | echo "Spend {$food->display()} on food\n"; 83 | 84 | //and save some money for a rainy day 85 | /** @var Currency $savings */ 86 | $savings = Crcy::create($crcyCd, 500); 87 | $accountant->writeTransaction( 88 | new SimpleTransaction($savingsAc, $bankAc, $savings, new StringType('rainy day')), 89 | new DateTime('2018-01-29') 90 | ); 91 | echo "Save {$savings->display()} for a rainy day\n\n"; 92 | 93 | echo "Journal entries for bank account\n\n"; 94 | //get the bank's account type so we can later get the titles for dr and cr entries 95 | $bankAcType = $accountant->fetchChart()->getAccount($bankAc)->getType(); 96 | //fetch the transactions 97 | $txns = $accountant->fetchAccountJournals($bankAc); 98 | /** @var \SAccounts\Transaction\SplitTransaction $txn */ 99 | foreach ($txns as $txn) { 100 | echo "#: {$txn->getId()}\n"; 101 | echo "Date: {$txn->getDate()->format('Y-M-D h:m:s')}\n"; 102 | echo "Note: {$txn->getNote()}\n"; 103 | echo "Src: {$txn->getSrc()}\n"; 104 | echo "Ref: {$txn->getRef()}\n"; 105 | 106 | $entries = $txn->getEntries(); 107 | /** @var \SAccounts\Transaction\Entry $entry */ 108 | echo str_pad($bankAcType->drTitle(), 12); 109 | echo $bankAcType->crTitle(); 110 | echo "\n"; 111 | foreach ($entries as $entry) { 112 | $type = $entry->getType()->equals(AccountType::DR()) ? 'dr' : 'cr'; 113 | $displayAmount = Crcy::create($crcyCd)->set($entry->getAmount()->get())->display()->get(); 114 | $dr = $type == 'dr' ? $displayAmount : ''; 115 | $cr = $type == 'cr' ? $displayAmount : ''; 116 | echo str_pad($dr, 12, ' ',STR_PAD_LEFT); 117 | echo str_pad($cr, 12, ' ', STR_PAD_LEFT); 118 | echo "\n"; 119 | } 120 | } -------------------------------------------------------------------------------- /phpunit.travis.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 27 | 28 | 32 | 33 | 34 | ./src/php/SAccounts 35 | 36 | ./examples 37 | ./src/php/SAccounts/Doctrine 38 | ./doctrine-db.php 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /scripts/oqgraph.sql: -------------------------------------------------------------------------------- 1 | INSTALL SONAME 'ha_oqgraph'; -------------------------------------------------------------------------------- /scripts/test-user.sql: -------------------------------------------------------------------------------- 1 | use mysql; 2 | create database if not exists test; 3 | create user 'test'@'localhost' identified by 'test'; 4 | grant all PRIVILEGES on test.* to 'test'@'localhost'; 5 | grant SUPER on *.* to 'test'@'localhost'; 6 | flush privileges; -------------------------------------------------------------------------------- /scripts/travis.install.mariadb.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | sudo apt-get install -y mariadb-plugin-oqgraph 3 | sudo mysql < ./scripts/oqgraph.sql 4 | sudo mysql < ./scripts/test-user.sql 5 | ./createmariadb.sh test test test localhost -------------------------------------------------------------------------------- /scripts/travis.install.mysql.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | sudo service mysql start 3 | sudo mysql < ./scripts/test-user.sql 4 | ./createmysqldb.sh test test test localhost -------------------------------------------------------------------------------- /sqltest.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Run SQL tests 3 | # dbName: test DB must exist 4 | # dbUid: test test user requires all privileges on database 5 | # dbPwd: test 6 | 7 | DBNAME=$1 8 | DBUID=$2 9 | DBPWD=$3 10 | DBHOST=$4 11 | 12 | OUTPUT="$(cat ./test/sql/*test.sql | mysql -h $DBHOST -u $DBUID -p$DBPWD $DBNAME -N)" 13 | PASSED=1 14 | if [[ $OUTPUT == *"Failed"* ]]; then 15 | PASSED=0; 16 | fi 17 | echo "Simple Accounts SQL Tests" 18 | echo "" 19 | echo "${OUTPUT}" 20 | echo "" 21 | 22 | if [[ $PASSED == 1 ]]; then 23 | echo "Tests passed"; 24 | EXIT=0 25 | else 26 | FAILED=$(echo $OUTPUT | grep 'Failed' | wc -l); 27 | echo "$FAILED Test(s) failed"; 28 | EXIT=-1 29 | fi 30 | 31 | NUMTEST=$(echo "$OUTPUT" | wc -l) 32 | echo "Number of tests: ${NUMTEST}" 33 | exit $EXIT; 34 | -------------------------------------------------------------------------------- /src/php/SAccounts/Account.php: -------------------------------------------------------------------------------- 1 | nominal = $nominal; 72 | $this->type = $type; 73 | $this->name = $name; 74 | $this->acDr = $dr; 75 | $this->acCr = $cr; 76 | } 77 | 78 | /** 79 | * Return current debit amount 80 | * 81 | * @return int 82 | */ 83 | public function dr() 84 | { 85 | return $this->acDr; 86 | } 87 | 88 | /** 89 | * Return current credit amount 90 | * 91 | * @return int 92 | */ 93 | public function cr() 94 | { 95 | return $this->acCr; 96 | } 97 | 98 | /** 99 | * Get the account balance 100 | * 101 | * Returns the current account balance. 102 | * 103 | * @return int 104 | * 105 | * @throws AccountsException 106 | */ 107 | public function getBalance() 108 | { 109 | return $this->type->balance($this->acDr, $this->acCr); 110 | } 111 | 112 | /** 113 | * Return account unique id (Nominal Code) 114 | * 115 | * @return Nominal 116 | */ 117 | public function getNominal() 118 | { 119 | return $this->nominal; 120 | } 121 | 122 | /** 123 | * Return account type 124 | * 125 | * @return AccountType 126 | */ 127 | public function getType() 128 | { 129 | return $this->type; 130 | } 131 | 132 | /** 133 | * Return account name 134 | * 135 | * @return string 136 | */ 137 | public function getName() 138 | { 139 | return $this->name; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/php/SAccounts/AccountType.php: -------------------------------------------------------------------------------- 1 | ['dr' => 'Debit', 'cr' => 'Credit'], 92 | self::CR => ['dr' => 'Debit', 'cr' => 'Credit'], 93 | self::ASSET => ['dr' => 'Increase', 'cr' => 'Decrease'], 94 | self::BANK => ['dr' => 'Increase', 'cr' => 'Decrease'], 95 | self::CUSTOMER => ['dr' => 'Increase', 'cr' => 'Decrease'], 96 | self::EXPENSE => ['dr' => 'Expense', 'cr' => 'Refund'], 97 | self::INCOME => ['dr' => 'Charge', 'cr' => 'Income'], 98 | self::LIABILITY => ['dr' => 'Decrease', 'cr' => 'Increase'], 99 | self::EQUITY => ['dr' => 'Decrease', 'cr' => 'Increase'], 100 | self::SUPPLIER => ['dr' => 'Decrease', 'cr' => 'Increase'], 101 | ]; 102 | 103 | /** 104 | * Return the debit column title for this account type 105 | * 106 | * @return string 107 | * @throws AccountsException 108 | */ 109 | public function drTitle(): string 110 | { 111 | if (!array_key_exists($this->value, $this->titles)) { 112 | throw new AccountsException('Invalid AccountType for drTitle: ' . $this->value); 113 | } 114 | 115 | return $this->titles[$this->value]['dr']; 116 | } 117 | 118 | /** 119 | * Return the credit column title for this account type 120 | * 121 | * @return string 122 | * @throws AccountsException 123 | */ 124 | public function crTitle(): string 125 | { 126 | if (!array_key_exists($this->value, $this->titles)) { 127 | throw new AccountsException('Invalid AccountType for crTitle: ' . $this->value); 128 | } 129 | 130 | return $this->titles[$this->value]['cr']; 131 | } 132 | 133 | /** 134 | * Return balance of debit and credit amounts 135 | * 136 | * @param int $dr debit amount 137 | * @param int $cr credit amount 138 | * 139 | * @return int 140 | * 141 | * @throws AccountsException 142 | */ 143 | public function balance(int $dr, int $cr): int 144 | { 145 | if (($this->value & self::DR) == self::DR) { 146 | //debit account type 147 | return $dr - $cr; 148 | } 149 | if (($this->value & self::CR) == self::CR) { 150 | //credit account type 151 | return $cr - $dr; 152 | } 153 | if (($this->value & self::REAL) == self::REAL) { 154 | //real balance - should always be zero as it is the root account 155 | return abs($cr - $dr); 156 | } 157 | 158 | throw new AccountsException('Cannot determine account type to set balance: ' . $this->value); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/php/SAccounts/Accountant.php: -------------------------------------------------------------------------------- 1 | dbAdapter = $dbAdapter; 59 | $this->chartId = $chartId; 60 | } 61 | 62 | /** 63 | * Create a new Chart from a definition and store it in the database 64 | * 65 | * @param string $chartName This needs to be unique 66 | * @param ChartDefinition $def 67 | * 68 | * @return int The Chart Id 69 | */ 70 | public function createChart(string $chartName, ChartDefinition $def): int 71 | { 72 | $this->chartId = FFor::create(['def' => $def, 'chartName' => $chartName]) 73 | ->root(function ($def) { 74 | return (new \DOMXPath($def->getDefinition()))->query('/chart/account')->item(0); 75 | }) 76 | ->tree(function () { 77 | return new Node(); 78 | }) 79 | ->chart(function ($tree, $chartName) { 80 | return new Chart($chartName, $tree); 81 | }) 82 | ->build(function ($root, $tree): void { 83 | $this->buildTreeFromXml($tree, $root, AccountType::toArray()); 84 | }) 85 | ->store(function (Chart $chart, Node $tree) { 86 | return $this->storeChart($chart->setRootNode($tree)); 87 | }) 88 | ->fyield('store'); 89 | 90 | return $this->chartId; 91 | } 92 | 93 | /** 94 | * Fetch a chart from storage 95 | * 96 | * @return Chart 97 | * 98 | * @throws AccountsException 99 | */ 100 | public function fetchChart(): Chart 101 | { 102 | if (is_null($this->chartId)) { 103 | throw new AccountsException(self::ERR1); 104 | } 105 | 106 | return FFor::create( 107 | [ 108 | 'chartId' => $this->chartId, 109 | 'dbAdapter' => $this->dbAdapter 110 | ] 111 | ) 112 | ->accounts(function (int $chartId, Adapter $dbAdapter) { 113 | return $dbAdapter 114 | ->query( 115 | "call sa_sp_get_tree('{$chartId}')", 116 | Adapter::QUERY_MODE_EXECUTE 117 | ) 118 | ->toArray(); 119 | }) 120 | ->chartName(function (int $chartId, Adapter $dbAdapter) { 121 | return $dbAdapter->query( 122 | "select name from sa_coa where id = {$chartId}", 123 | Adapter::QUERY_MODE_EXECUTE 124 | ) 125 | ->current() 126 | ->offsetGet('name'); 127 | }) 128 | ->rootAc(function ($accounts) { 129 | return array_shift($accounts); 130 | }) 131 | ->root(function ($rootAc, $accounts) { 132 | $root = new Node( 133 | new Account( 134 | new Nominal($rootAc['nominal']), 135 | AccountType::{$rootAc['type']}(), 136 | $rootAc['name'], 137 | (int) $rootAc['acDr'], 138 | (int) $rootAc['acCr'] 139 | ) 140 | ); 141 | 142 | return $this->buildTreeFromDb( 143 | $root, 144 | $accounts, 145 | $rootAc['destid'] 146 | ); 147 | }) 148 | ->chart(function (string $chartName, Node $root, int $chartId) { 149 | return new Chart($chartName, $root, $chartId); 150 | }) 151 | ->fyield('chart'); 152 | } 153 | 154 | /** 155 | * Write a Transaction to the Journal and update the Chart 156 | * 157 | * @param SplitTransaction $txn 158 | * @param \DateTime|null $dateTime 159 | * 160 | * @return int Transaction Id 161 | * @throws AccountsException 162 | */ 163 | public function writeTransaction(SplitTransaction $txn, ?\DateTime $dateTime = null): int 164 | { 165 | if (is_null($this->chartId)) { 166 | throw new AccountsException(self::ERR1); 167 | } 168 | 169 | return (int) FFor::create( 170 | [ 171 | 'txn' => $txn, 172 | 'dateTime' => is_null($dateTime) ? $dateTime : $dateTime->format('Y-m-d h:m:s'), 173 | 'txns' => $txn->getEntries()->toArray(), 174 | 'dbAdapter' => $this->dbAdapter, 175 | 'chartId' => $this->chartId 176 | ] 177 | ) 178 | ->stmnt(function (Adapter $dbAdapter) { 179 | return $dbAdapter->query( 180 | "select sa_fu_add_txn(?, ?, ?, ?, ?, ?, ?, ?) as txnId", 181 | Adapter::QUERY_MODE_PREPARE 182 | ); 183 | }) 184 | ->write(function (StatementInterface $stmnt, $dateTime, SplitTransaction $txn, array $txns, int $chartId) { 185 | return $stmnt->execute( 186 | [ 187 | $this->chartId, 188 | $txn->getNote(), 189 | $dateTime, 190 | is_null($txn->getSrc()) ? null : $txn->getSrc(), 191 | is_null($txn->getRef()) ? null : $txn->getRef(), 192 | implode( 193 | ',', 194 | array_map( 195 | function (Entry $entry) { 196 | return $entry->getId()->get(); 197 | }, 198 | $txns 199 | ) 200 | ), 201 | implode( 202 | ',', 203 | array_map( 204 | function (Entry $entry) { 205 | return $entry->getAmount(); 206 | }, 207 | $txns 208 | ) 209 | ), 210 | implode( 211 | ',', 212 | array_map( 213 | function (Entry $entry) { 214 | return $entry->getType()->getKey(); 215 | }, 216 | $txns 217 | ) 218 | ) 219 | ] 220 | )->current()['txnId']; 221 | }) 222 | ->fyield('write'); 223 | } 224 | 225 | /** 226 | * Fetch a journal transaction identified by its journal id 227 | * 228 | * @param int $jrnId 229 | * 230 | * @return SplitTransaction 231 | */ 232 | public function fetchTransaction(int $jrnId): SplitTransaction 233 | { 234 | return FFor::create( 235 | [ 236 | 'jrnId' => $jrnId, 237 | 'dbAdapter' => $this->dbAdapter 238 | ] 239 | ) 240 | ->journal(function (int $jrnId, Adapter $dbAdapter) { 241 | $journal = $dbAdapter->query('select * from sa_journal where id = ?') 242 | ->execute([$jrnId]) 243 | ->getResource()->fetchAll(\PDO::FETCH_ASSOC); 244 | return array_pop($journal); 245 | }) 246 | ->entries(function (int $jrnId, Adapter $dbAdapter) { 247 | return $dbAdapter->query('select * from sa_journal_entry where jrnId = ?') 248 | ->execute([$jrnId]) 249 | ->getResource()->fetchAll(\PDO::FETCH_ASSOC); 250 | }) 251 | ->txn(function (array $journal, array $entries, int $jrnId) { 252 | $txn = (new SplitTransaction( 253 | $journal['note'], 254 | $journal['src'], 255 | (int) $journal['ref'], 256 | new \DateTime($journal['date']) 257 | ))->setId($jrnId); 258 | 259 | foreach ($entries as $entry) { 260 | $txn->addEntry( 261 | new Entry( 262 | new Nominal($entry['nominal']), 263 | (int) ($entry['acDr'] == 0 ? $entry['acCr'] : $entry['acDr']), 264 | ($entry['acDr'] == 0 ? AccountType::CR() : AccountType::DR()) 265 | ) 266 | ); 267 | } 268 | 269 | return $txn; 270 | }) 271 | ->fyield('txn'); 272 | } 273 | 274 | /** 275 | * Fetch journal entries for an account 276 | * 277 | * The returned Set is a Set of SplitTransactions with only the entries for 278 | * the required Account. They will therefore be unbalanced. 279 | * 280 | * @param Nominal $nominal 281 | * 282 | * @return Set 283 | */ 284 | public function fetchAccountJournals(Nominal $nominal): Set 285 | { 286 | return FFor::create( 287 | [ 288 | 'dbAdapter' => $this->dbAdapter, 289 | 'nominal' => $nominal, 290 | 'chartId' => $this->chartId 291 | ] 292 | ) 293 | ->sql(function (Adapter $dbAdapter) { 294 | return new Sql($dbAdapter); 295 | }) 296 | ->select(function (Sql $sql, Nominal $nominal, int $chartId) { 297 | return $sql->select(['j' => 'sa_journal']) 298 | ->join( 299 | ['e' => 'sa_journal_entry'], 300 | 'j.id = e.jrnId', 301 | ['nominal', 'acDr', 'acCr'] 302 | ) 303 | ->columns(['id', 'note', 'date', 'src', 'ref']) 304 | ->where( 305 | [ 306 | 'e.nominal' => $nominal->get(), 307 | 'j.chartId' => $chartId 308 | ] 309 | ); 310 | }) 311 | ->entries(function (Adapter $dbAdapter, Sql $sql, Select $select) { 312 | return $dbAdapter->query($sql->buildSqlString($select)) 313 | ->execute() 314 | ->getResource() 315 | ->fetchAll(\PDO::FETCH_ASSOC); 316 | }) 317 | ->build(function (array $entries) { 318 | $transactions = []; 319 | $jrnId = -1; 320 | foreach ($entries as $entry) { 321 | $entry['id'] = (int) $entry['id']; 322 | if ($entry['id'] != $jrnId) { 323 | $jrnId = $entry['id']; 324 | $txn = new SplitTransaction( 325 | $entry['note'], 326 | $entry['src'], 327 | (int) $entry['ref'], 328 | new \DateTime($entry['date']) 329 | ); 330 | $txn->setId($jrnId); 331 | $transactions[] = $txn; 332 | } 333 | $txn->addEntry( 334 | new Entry( 335 | new Nominal($entry['nominal']), 336 | (int) (empty($entry['acDr']) ? $entry['acCr'] : $entry['acDr']), 337 | empty($entry['acDr']) ? AccountType::CR() : AccountType::DR() 338 | ) 339 | ); 340 | } 341 | 342 | return new Set($transactions); 343 | }) 344 | ->fyield('build'); 345 | } 346 | 347 | 348 | /** 349 | * Add an account (ledger) to the chart 350 | * 351 | * Exceptions thrown if parent doesn't exist or you try to add a second 352 | * root account 353 | * 354 | * @param Nominal $nominal 355 | * @param AccountType $type 356 | * @param string $name 357 | * @param Nominal|null $prntNominal 358 | * 359 | * @return Accountant 360 | * 361 | * @throws DbException 362 | */ 363 | public function addAccount( 364 | Nominal $nominal, 365 | AccountType $type, 366 | string $name, 367 | ?Nominal $prntNominal = null 368 | ): Accountant { 369 | try { 370 | $this->dbAdapter->query('call sa_sp_add_ledger(?, ?, ?, ?, ?)') 371 | ->execute( 372 | [ 373 | $this->chartId, 374 | $nominal(), 375 | $type->getKey(), 376 | $name, 377 | is_null($prntNominal) ? '' : $prntNominal() 378 | ] 379 | ); 380 | 381 | return $this; 382 | } catch (InvalidQueryException $e) { 383 | throw new DbException($e); 384 | } catch (PDOException $e) { 385 | throw new DbException($e); 386 | } 387 | } 388 | 389 | /** 390 | * Delete an account (ledger) and all its child accounts 391 | * Exception thrown if the account has non zero debit or credit amounts 392 | * 393 | * @param Nominal $nominal 394 | * 395 | * @return Accountant 396 | * 397 | * @throws DbException 398 | */ 399 | public function delAccount(Nominal $nominal): Accountant 400 | { 401 | try { 402 | $this->dbAdapter->query('call sa_sp_del_ledger(?, ?)') 403 | ->execute( 404 | [ 405 | $this->chartId, 406 | $nominal() 407 | ] 408 | ); 409 | 410 | return $this; 411 | } catch (InvalidQueryException $e) { 412 | throw new DbException($e); 413 | } 414 | } 415 | 416 | 417 | /** 418 | * Build chart tree from database records 419 | * 420 | * @param Node $node 421 | * @param array $accounts 422 | * @param int $origId 423 | * 424 | * @return Node 425 | */ 426 | protected function buildTreeFromDb(Node $node, array $accounts, $origId): Node 427 | { 428 | $childAccounts = array_filter( 429 | $accounts, 430 | function ($account) use ($origId) { 431 | return $account['origid'] == $origId; 432 | } 433 | ); 434 | 435 | foreach ($childAccounts as $childAccount) { 436 | $childNode = new Node( 437 | new Account( 438 | new Nominal($childAccount['nominal']), 439 | AccountType::{$childAccount['type']}(), 440 | $childAccount['name'], 441 | (int) $childAccount['acDr'], 442 | (int) $childAccount['acCr'] 443 | ) 444 | ); 445 | $childNode = $this->buildTreeFromDb($childNode, $accounts, $childAccount['destid']); 446 | $node->addChild($childNode); 447 | } 448 | 449 | return $node; 450 | } 451 | 452 | /** 453 | * Recursively build chart of account tree from XML 454 | * 455 | * @param Node $tree 456 | * @param \DOMNode $node 457 | * @param array $accountTypes 458 | */ 459 | protected function buildTreeFromXml(Node $tree, \DOMNode $node, array $accountTypes): void 460 | { 461 | //create current node 462 | [$nominal, $type, $name] = FFor::create( 463 | [ 464 | 'attributes' => $node->attributes, 465 | 'accountTypes' => $accountTypes 466 | ] 467 | ) 468 | ->nominal(function (\DOMNamedNodeMap $attributes) { 469 | return new Nominal($attributes->getNamedItem('nominal')->nodeValue); 470 | }) 471 | ->name(function (\DOMNamedNodeMap $attributes) { 472 | return $attributes->getNamedItem('name')->nodeValue; 473 | }) 474 | ->type(function (\DOMNamedNodeMap $attributes, $accountTypes) { 475 | return new AccountType( 476 | $accountTypes[strtoupper($attributes->getNamedItem('type')->nodeValue)] 477 | ); 478 | }) 479 | ->fyield('nominal', 'type', 'name'); 480 | 481 | $tree->setValue(new Account($nominal, $type, $name, 0, 0)); 482 | 483 | //recurse through sub accounts 484 | foreach ($node->childNodes as $childNode) { 485 | if ($childNode instanceof \DOMElement) { 486 | $childTree = new Node(); 487 | $tree->addChild($childTree); 488 | $this->buildTreeFromXml($childTree, $childNode, $accountTypes); 489 | } 490 | } 491 | } 492 | 493 | /** 494 | * Store chart definition 495 | * 496 | * @param Chart $chart 497 | * 498 | * @return int New Chart Id 499 | */ 500 | protected function storeChart(Chart $chart): int 501 | { 502 | return FFor::create( 503 | [ 504 | 'chart' => $chart, 505 | 'root' => $chart->getTree(), 506 | 'dbAdapter' => $this->dbAdapter 507 | ] 508 | ) 509 | ->chartId(function (Chart $chart, Adapter $dbAdapter) { 510 | $res = $dbAdapter->query( 511 | "select sa_fu_add_chart('{$chart->getName()}')", 512 | Adapter::QUERY_MODE_EXECUTE 513 | ) 514 | ->current() 515 | ->getArrayCopy(); 516 | 517 | return (int) \array_pop($res); 518 | }) 519 | ->build(function (Node $root, int $chartId, Adapter $dbAdapter): void { 520 | $root->accept(new NodeSaver($chartId, $this->dbAdapter)); 521 | }) 522 | ->fyield('chartId'); 523 | } 524 | } 525 | -------------------------------------------------------------------------------- /src/php/SAccounts/AccountsException.php: -------------------------------------------------------------------------------- 1 | chartName = $name; 61 | $this->tree = Match::on($tree) 62 | ->Tree_Node_Node($tree) 63 | ->null(new Node()) 64 | ->value(); 65 | $this->id = (int) $internalId; 66 | } 67 | 68 | /** 69 | * Get an account from the chart 70 | * 71 | * @param Nominal $nId 72 | * 73 | * @return Account|null 74 | */ 75 | public function getAccount(Nominal $nId) 76 | { 77 | return Match::on($this->tryGetNode($nId, self::ERR_INVALAC)) 78 | ->Monad_FTry_Success(function ($account) { 79 | return FTry::with($account->flatten()->getValue()); 80 | }) 81 | ->value() 82 | ->pass() 83 | ->value(); 84 | } 85 | 86 | /** 87 | * Does this chart have specified account 88 | * 89 | * @param Nominal $nId 90 | * @return bool 91 | */ 92 | public function hasAccount(Nominal $nId) 93 | { 94 | return Match::on(FTry::with(function () use ($nId): void { 95 | $this->getAccount($nId); 96 | })) 97 | ->Monad_FTry_Success(true) 98 | ->Monad_FTry_Failure(false) 99 | ->value(); 100 | } 101 | 102 | /** 103 | * Get Nominal of parent for an account 104 | * 105 | * @param Nominal $nId 106 | * 107 | * @return null|Nominal 108 | * 109 | * @throws AccountsException 110 | */ 111 | public function getParentId(Nominal $nId): ?Nominal 112 | { 113 | return Match::on( 114 | Match::on($this->tryGetNode($nId, self::ERR_INVALAC)) 115 | ->Monad_FTry_Success(function ($node) { 116 | return Match::on($node->flatten()->getParent()); 117 | }) 118 | ->value() 119 | ->pass() 120 | ->value() 121 | ) 122 | ->Tree_Node_Node(function ($node) { 123 | /** @var Account $v */ 124 | $v = $node->getValue(); 125 | return is_null($v) ? null : $v->getNominal(); 126 | }) 127 | ->value(); 128 | } 129 | 130 | /** 131 | * Return account tree 132 | * 133 | * @return Node 134 | */ 135 | public function getTree(): Node 136 | { 137 | return $this->tree; 138 | } 139 | 140 | /** 141 | * Return chart name 142 | * 143 | * @return string 144 | */ 145 | public function getName(): string 146 | { 147 | return $this->chartName; 148 | } 149 | 150 | /** 151 | * Set the Chart's root node 152 | * 153 | * @param Node $root 154 | * 155 | * @return $this 156 | */ 157 | public function setRootNode(Node $root): Chart 158 | { 159 | $this->tree = $root; 160 | 161 | return $this; 162 | } 163 | 164 | /** 165 | * Return the chart id 166 | * 167 | * @return int 168 | */ 169 | public function id() 170 | { 171 | return $this->id; 172 | } 173 | 174 | /** 175 | * @param Nominal $nId 176 | * @param $exceptionMessage 177 | * 178 | * @return FTry 179 | */ 180 | protected function tryGetNode(Nominal $nId, $exceptionMessage): FTry 181 | { 182 | return FTry::with(function () use ($nId, $exceptionMessage) { 183 | $node = $this->findNode($nId); 184 | if (is_null($node)) { 185 | throw new AccountsException($exceptionMessage); 186 | } 187 | return $node; 188 | }); 189 | } 190 | 191 | /** 192 | * Find an account node using its nominal code 193 | * 194 | * @param Nominal $nId 195 | * @return Node|null 196 | */ 197 | protected function findNode(Nominal $nId): ?Node 198 | { 199 | return $this->tree->accept(new NodeFinder($nId)); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/php/SAccounts/ChartDefinition.php: -------------------------------------------------------------------------------- 1 | xmlFileName = $xmlFileName; 37 | } 38 | 39 | /** 40 | * Get chart definition as a DOMDocument 41 | * 42 | * @return \DOMDocument 43 | * @throws AccountsException 44 | */ 45 | public function getDefinition(): \DOMDocument 46 | { 47 | $err = ''; 48 | set_error_handler(function ($number, $error) use (&$err): void { 49 | $err = $error; 50 | if (preg_match('/^DOMDocument::load\(\): (.+)$/', $error, $m) === 1) { 51 | throw new AccountsException($m[1]); 52 | } 53 | }); 54 | $dom = new \DOMDocument(); 55 | $dom->load($this->xmlFileName); 56 | 57 | if (!$dom->schemaValidate(dirname(dirname(__DIR__)) . '/xsd/chart-definition.xsd')) { 58 | throw new AccountsException('Definition does not validate: ' . $err); 59 | } 60 | 61 | restore_error_handler(); 62 | 63 | return $dom; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/php/SAccounts/DbException.php: -------------------------------------------------------------------------------- 1 | getMessage(); 24 | $matches = []; 25 | if ($previous instanceof InvalidQueryException) { 26 | preg_match( 27 | '/.*45000 - (?P\d+) - (?P[\w, ]+)\)/', 28 | $errMsg, 29 | $matches 30 | ); 31 | } 32 | if ($previous instanceof \PDOException) { 33 | preg_match('/.*\[45000\].*: (?P\d+) (?P[\w, ]+)/', $errMsg, $matches); 34 | } 35 | parent::__construct($matches['err'], (int) $matches['code'], $previous); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/php/SAccounts/Doctrine/Version20180428092520.php: -------------------------------------------------------------------------------- 1 | connection->getDatabasePlatform() 35 | ->registerDoctrineTypeMapping('enum', 'string'); 36 | $this->sqlSrcDir = dirname(dirname(dirname(dirname(__FILE__)))) . '/sql/mariadb'; 37 | } 38 | 39 | /** 40 | * @param Schema $schema 41 | */ 42 | public function up(Schema $schema): void 43 | { 44 | $this->createTables(); 45 | $this->createProcs(); 46 | $this->createTriggers(); 47 | } 48 | 49 | /** 50 | * @param Schema $schema 51 | */ 52 | public function down(Schema $schema): void 53 | { 54 | $this->dropProcs(); 55 | $this->dropTables(); 56 | } 57 | 58 | protected function createTables(): void 59 | { 60 | $sql = file_get_contents($this->sqlSrcDir . '/build-tables.sql'); 61 | 62 | $matches = []; 63 | preg_match_all('/DROP TABLE .*;/isU', $sql, $matches); 64 | 65 | foreach ($matches as $statement) { 66 | $this->addSql($statement); 67 | } 68 | 69 | $matches = []; 70 | preg_match_all('/CREATE TABLE .*;/isU', $sql, $matches); 71 | 72 | foreach ($matches as $statement) { 73 | $this->addSql($statement); 74 | } 75 | 76 | $matches = []; 77 | preg_match_all('/INSERT.*\);/isU', $sql, $matches); 78 | 79 | foreach ($matches as $statement) { 80 | $this->addSql($statement); 81 | } 82 | } 83 | 84 | protected function createProcs(): void 85 | { 86 | $sql = file_get_contents($this->sqlSrcDir . '/build-procs.sql'); 87 | 88 | $matches = []; 89 | preg_match_all('/DROP FUNCTION .*;/isU', $sql, $matches); 90 | 91 | foreach ($matches as $statement) { 92 | $this->addSql($statement); 93 | } 94 | 95 | $matches = []; 96 | preg_match_all('/DROP PROCEDURE .*;/isU', $sql, $matches); 97 | 98 | foreach ($matches as $statement) { 99 | $this->addSql($statement); 100 | } 101 | 102 | $matches = []; 103 | preg_match_all('!CREATE DEFINER .*//!isU', $sql, $matches); 104 | 105 | foreach ($matches as $statement) { 106 | $this->addSql($statement); 107 | } 108 | } 109 | 110 | protected function createTriggers(): void 111 | { 112 | $sql = file_get_contents($this->sqlSrcDir . '/build-triggers.sql'); 113 | 114 | $matches = []; 115 | preg_match_all('/DROP TRIGGER .*;/isU', $sql, $matches); 116 | 117 | foreach ($matches as $statement) { 118 | $this->addSql($statement); 119 | } 120 | 121 | $matches = []; 122 | preg_match_all('!CREATE DEFINER .*//!isU', $sql, $matches); 123 | 124 | foreach ($matches as $statement) { 125 | $this->addSql($statement); 126 | } 127 | } 128 | 129 | protected function dropProcs(): void 130 | { 131 | $sql = file_get_contents($this->sqlSrcDir . '/drop-procs.sql'); 132 | 133 | $matches = []; 134 | preg_match_all('/DROP FUNCTION .*;/isU', $sql, $matches); 135 | 136 | foreach ($matches as $statement) { 137 | $this->addSql($statement); 138 | } 139 | 140 | $matches = []; 141 | preg_match_all('/DROP PROCEDURE .*;/isU', $sql, $matches); 142 | 143 | foreach ($matches as $statement) { 144 | $this->addSql($statement); 145 | } 146 | } 147 | 148 | protected function dropTables(): void 149 | { 150 | $sql = file_get_contents($this->sqlSrcDir . '/drop-tables.sql'); 151 | 152 | $matches = []; 153 | preg_match_all('/DROP TABLE .*;/isU', $sql, $matches); 154 | 155 | foreach ($matches as $statement) { 156 | $this->addSql($statement); 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/php/SAccounts/Nominal.php: -------------------------------------------------------------------------------- 1 | set($value); 43 | } 44 | 45 | /** 46 | * Set the object value. 47 | * Forces type 48 | * 49 | * @param mixed $value 50 | * 51 | * @return Nominal 52 | * 53 | * @see typeOf 54 | */ 55 | public function set($value): Nominal 56 | { 57 | $this->value = $this->typeOf($value); 58 | 59 | return $this; 60 | } 61 | 62 | /** 63 | * Get the value of the object typed properly 64 | * 65 | * @return string 66 | */ 67 | public function get() 68 | { 69 | return $this->value; 70 | } 71 | 72 | /** 73 | * Magic invoke method 74 | * Proxy to get() 75 | * 76 | * @see get 77 | * 78 | * @return string 79 | */ 80 | public function __invoke(): string 81 | { 82 | return $this->get(); 83 | } 84 | 85 | /** 86 | * Magic method - convert to string 87 | * Proxy to get() 88 | * 89 | * @return string 90 | */ 91 | public function __toString(): string 92 | { 93 | return $this->get(); 94 | } 95 | 96 | /** 97 | * This will filter out any non numeric characters. You may potentially 98 | * get an empty string 99 | * 100 | * @param mixed $value 101 | * @return string 102 | */ 103 | protected function typeOf($value): string 104 | { 105 | return (string) $this->filter($value); 106 | } 107 | 108 | /** 109 | * Lifted entirely from the Zend framework so that we don't have to include 110 | * the Zend\Filter package and all its dependencies. 111 | * 112 | * @param string $value 113 | * @return string 114 | * zendframework/zend-filter/Zend/Filter/Digits.php 115 | * Zend Framework (http://framework.zend.com/) 116 | * 117 | * @link http://github.com/zendframework/zf2 for the canonical source repository 118 | * @copyright Copyright (c) 2005-2014 Zend Technologies USA Inc. (http://www.zend.com) 119 | * @license http://framework.zend.com/license/new-bsd New BSD License 120 | * Defined by Zend\Filter\FilterInterface 121 | * 122 | * Returns the string $value, removing all but digit characters 123 | * 124 | * If the value provided is non-scalar, the value will remain unfiltered 125 | * 126 | */ 127 | protected function filter($value): string 128 | { 129 | if (!is_scalar($value)) { 130 | return $value; 131 | } 132 | $value = (string) $value; 133 | 134 | if (!$this->hasPcreUnicodeSupport()) { 135 | // POSIX named classes are not supported, use alternative 0-9 match 136 | return preg_replace('/[^0-9]/', '', $value); 137 | } 138 | 139 | if (extension_loaded('mbstring')) { 140 | // Filter for the value with mbstring 141 | return preg_replace('/[^[:digit:]]/', '', $value); 142 | } 143 | 144 | // Filter for the value without mbstring 145 | return preg_replace('/[\p{^N}]/', '', $value); 146 | } 147 | 148 | /** 149 | * Lifted entirely from Zend Framework (http://framework.zend.com/) so we don't have 150 | * to include Zend/Stdlib 151 | * 152 | * @link http://github.com/zendframework/zf2 for the canonical source repository 153 | * @copyright Copyright (c) 2005-2014 Zend Technologies USA Inc. (http://www.zend.com) 154 | * @license http://framework.zend.com/license/new-bsd New BSD License 155 | * Is PCRE compiled with Unicode support? 156 | * 157 | * @return bool 158 | */ 159 | protected function hasPcreUnicodeSupport(): bool 160 | { 161 | if (static::$hasPcreUnicodeSupport === null) { 162 | ErrorHandler::start(); 163 | static::$hasPcreUnicodeSupport = 164 | defined('PREG_BAD_UTF8_OFFSET_ERROR') && preg_match('/\pL/u', 'a') == 1; 165 | ErrorHandler::stop(); 166 | } 167 | return static::$hasPcreUnicodeSupport; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/php/SAccounts/Transaction/Entries.php: -------------------------------------------------------------------------------- 1 | vUnion(static::create([$entry])); 45 | } 46 | 47 | /** 48 | * Check balance of entries, returns true if they balance else false 49 | * 50 | * @return bool 51 | */ 52 | public function checkBalance(): bool 53 | { 54 | $balance = $this->reduce( 55 | function ($carry, Entry $entry) { 56 | $amount = $entry->getAmount(); 57 | return AccountType::DR()->equals($entry->getType()) ? $carry - $amount : $carry + $amount; 58 | }, 59 | 0 60 | ); 61 | 62 | return $balance == 0; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/php/SAccounts/Transaction/Entry.php: -------------------------------------------------------------------------------- 1 | entryId = $entryId; 55 | $this->amount = $amount; 56 | $this->type = $this->checkType($type) 57 | ->pass() 58 | ->value(); 59 | } 60 | 61 | /** 62 | * @return Nominal 63 | */ 64 | public function getId(): Nominal 65 | { 66 | return $this->entryId; 67 | } 68 | 69 | /** 70 | * @return int 71 | */ 72 | public function getAmount(): int 73 | { 74 | return $this->amount; 75 | } 76 | 77 | /** 78 | * @return AccountType 79 | */ 80 | public function getType(): AccountType 81 | { 82 | return $this->type; 83 | } 84 | 85 | /** 86 | * @param AccountType $type 87 | * @return FTry 88 | */ 89 | protected function checkType(AccountType $type): FTry 90 | { 91 | return Match::on($type->getValue()) 92 | ->test(AccountType::CR, function () { 93 | return FTry::with(AccountType::CR()); 94 | }) 95 | ->test(AccountType::DR, function () { 96 | return FTry::with(AccountType::DR()); 97 | }) 98 | ->any(function () { 99 | return FTry::with(new AccountsException(self::ERR_NOTYPE)); 100 | }) 101 | ->value(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/php/SAccounts/Transaction/SimpleTransaction.php: -------------------------------------------------------------------------------- 1 | addEntry(new Entry($drAc, $amount, AccountType::DR())); 46 | $this->addEntry(new Entry($crAc, $amount, AccountType::CR())); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/php/SAccounts/Transaction/SplitTransaction.php: -------------------------------------------------------------------------------- 1 | Monad_Option_Some(function ($opt): void { 76 | $this->date = $opt->value(); 77 | }) 78 | ->Monad_Option_None(function (): void { 79 | $this->date = new \DateTime(); 80 | }); 81 | 82 | Match::on(Option::create($note)) 83 | ->Monad_Option_Some(function ($opt): void { 84 | $this->note = $opt->value(); 85 | }) 86 | ->Monad_Option_None(function (): void { 87 | $this->note = null; 88 | }); 89 | 90 | Match::on(Option::create($src)) 91 | ->Monad_Option_Some(function ($opt): void { 92 | $this->src = $opt->value(); 93 | }) 94 | ->Monad_Option_None(function (): void { 95 | $this->src = null; 96 | }); 97 | 98 | Match::on(Option::create($ref)) 99 | ->Monad_Option_Some(function ($opt): void { 100 | $this->ref = $opt->value(); 101 | }) 102 | ->Monad_Option_None(function (): void { 103 | $this->ref = null; 104 | }); 105 | 106 | $this->entries = new Entries(); 107 | } 108 | 109 | /** 110 | * @param int $txnId 111 | * 112 | * @return SplitTransaction 113 | */ 114 | public function setId(int $txnId): SplitTransaction 115 | { 116 | $this->txnId = $txnId; 117 | return $this; 118 | } 119 | 120 | /** 121 | * @return int|null 122 | */ 123 | public function getId(): ?int 124 | { 125 | return $this->txnId ?? null; 126 | } 127 | 128 | /** 129 | * @return \DateTime 130 | */ 131 | public function getDate(): \DateTime 132 | { 133 | return $this->date; 134 | } 135 | 136 | /** 137 | * @return string 138 | */ 139 | public function getNote(): string 140 | { 141 | return $this->note ?? ''; 142 | } 143 | 144 | /** 145 | * @return string 146 | */ 147 | public function getSrc(): string 148 | { 149 | return $this->src ?? ''; 150 | } 151 | 152 | /** 153 | * @return int 154 | */ 155 | public function getRef(): int 156 | { 157 | return $this->ref ?? 0; 158 | } 159 | 160 | /** 161 | * Add a transaction entry 162 | * 163 | * @param Entry $entry 164 | * 165 | * @return SplitTransaction 166 | */ 167 | public function addEntry(Entry $entry): SplitTransaction 168 | { 169 | $this->entries = $this->entries->addEntry($entry); 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Do the entries balance? 176 | * 177 | * @return bool 178 | */ 179 | public function checkBalance(): bool 180 | { 181 | return $this->entries->checkBalance(); 182 | } 183 | 184 | /** 185 | * @return Entries 186 | */ 187 | public function getEntries(): Entries 188 | { 189 | return $this->entries; 190 | } 191 | 192 | /** 193 | * @param Nominal $id 194 | * 195 | * @return Entry 196 | * 197 | * @throws AccountsException 198 | */ 199 | public function getEntry(Nominal $id): Entry 200 | { 201 | $entries = array_values($this->entries->filter(function (Entry $entry) use ($id) { 202 | return $entry->getId()->get() === $id(); 203 | })->toArray()); 204 | 205 | if (count($entries) == 0) { 206 | throw new AccountsException(self::ERR1); 207 | } 208 | 209 | return $entries[0]; 210 | } 211 | 212 | 213 | /** 214 | * Get amount if the account is balanced 215 | * 216 | * @return int 217 | * 218 | * @throw AccountsException 219 | */ 220 | public function getAmount(): int 221 | { 222 | return Match::create(Option::create($this->entries->checkBalance(), false)) 223 | ->Monad_Option_Some( 224 | function () { 225 | $tot = 0; 226 | foreach ($this->entries as $entry) { 227 | $tot += $entry->getAmount(); 228 | } 229 | return $tot / 2; 230 | } 231 | ) 232 | ->Monad_Option_None(function (): void { 233 | throw new AccountsException('No amount for unbalanced transaction'); 234 | }) 235 | ->value(); 236 | } 237 | 238 | /** 239 | * Return debit account ids 240 | * return zero, one or more Nominals in an array 241 | * 242 | * @return array [Nominal] 243 | */ 244 | public function getDrAc(): array 245 | { 246 | $acs = []; 247 | foreach ($this->getEntries() as $entry) { 248 | if (AccountType::DR()->equals($entry->getType())) { 249 | $acs[] = $entry->getId(); 250 | } 251 | } 252 | 253 | return $acs; 254 | } 255 | 256 | /** 257 | * Return credit account ids 258 | * return zero, one or more Nominals in an array 259 | * 260 | * @return array [Nominal] 261 | */ 262 | public function getCrAc(): array 263 | { 264 | $acs = []; 265 | foreach ($this->getEntries() as $entry) { 266 | if (AccountType::CR()->equals($entry->getType())) { 267 | $acs[] = $entry->getId(); 268 | } 269 | } 270 | 271 | return $acs; 272 | } 273 | 274 | /** 275 | * Is this a simple transaction, i.e. 1 dr and 1 cr entry 276 | * 277 | * @return bool 278 | */ 279 | public function isSimple() 280 | { 281 | return count($this->getDrAc()) == 1 282 | && count($this->getCrAc()) == 1; 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/php/SAccounts/Visitor/ChartArray.php: -------------------------------------------------------------------------------- 1 | crcy = !is_null($crcy) ? $crcy : new Currency(0, '', '', 0); 42 | $this->asInt = is_null($crcy); 43 | } 44 | 45 | /** 46 | * @param NodeInterface $node 47 | * 48 | * @return array [[nominal, acName, dr, cr, balance],...] 49 | */ 50 | public function visit(NodeInterface $node): array 51 | { 52 | return FFor::create([ 53 | 'ret' => [], 54 | 'node' => $node, 55 | 'ac' => $node->getValue() 56 | ]) 57 | ->dr(function ($ac) { 58 | return $this->asInt 59 | ? $ac->dr() 60 | : $this->crcy->setValue($ac->dr())->getAsFloat(); 61 | }) 62 | ->cr(function ($ac) { 63 | return $this->asInt 64 | ? $ac->cr() 65 | : $this->crcy->setValue($ac->cr())->getAsFloat(); 66 | }) 67 | ->balance(function ($ac) { 68 | return $this->asInt 69 | ? $ac->getBalance() 70 | : $this->crcy->setValue($ac->getBalance())->getAsFloat(); 71 | }) 72 | ->loop(function ($dr, $cr, $balance, $ac, $node, $ret) { 73 | $ret[] = [ 74 | $ac->getNominal()->get(), 75 | $ac->getName(), 76 | $dr, 77 | $cr, 78 | $balance 79 | ]; 80 | 81 | foreach ($node->getChildren() as $child) { 82 | $ret = array_merge($ret, $child->accept($this)); 83 | } 84 | return $ret; 85 | }) 86 | ->fyield('loop'); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/php/SAccounts/Visitor/ChartPrinter.php: -------------------------------------------------------------------------------- 1 | crcy = $crcy; 36 | $this->headerPrinted = false; 37 | } 38 | 39 | /** 40 | * @param NodeInterface $node 41 | * 42 | * @return mixed 43 | */ 44 | public function visit(NodeInterface $node) 45 | { 46 | if (!$this->headerPrinted) { 47 | echo "Nominal Name DR CR Balance\n"; 48 | $this->headerPrinted = true; 49 | } 50 | 51 | /** @var Account $ac */ 52 | $ac = $node->getValue(); 53 | $nominal = str_pad($ac->getNominal()->get(), 8); 54 | $name = str_pad($ac->getName(), 20); 55 | $dr = str_pad($this->crcy->setValue($ac->dr())->display(), 15, ' ', STR_PAD_LEFT); 56 | $cr = str_pad($this->crcy->setValue($ac->cr())->display(), 15, ' ', STR_PAD_LEFT); 57 | $balStr = $this->crcy->setValue($ac->getBalance())->display(); 58 | $balance = str_pad($balStr, 15, ' ', STR_PAD_LEFT); 59 | echo "{$nominal}{$name}{$dr}{$cr}{$balance}\n"; 60 | 61 | foreach ($node->getChildren() as $child) { 62 | $child->accept($this); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/php/SAccounts/Visitor/NodeFinder.php: -------------------------------------------------------------------------------- 1 | valueToFind = $valueToFind->get(); 35 | } 36 | 37 | /** 38 | * @param NodeInterface $node 39 | * 40 | * @return NodeInterface|null 41 | */ 42 | public function visit(NodeInterface $node): ?NodeInterface 43 | { 44 | $currAc = $node->getValue(); 45 | 46 | if ($currAc instanceof Account && $currAc->getNominal()->get() == $this->valueToFind) { 47 | return $node; 48 | } 49 | 50 | foreach ($node->getChildren() as $child) { 51 | /** @noinspection PhpVoidFunctionResultUsedInspection */ 52 | $found = $child->accept($this); 53 | if (!is_null($found)) { 54 | return $found; 55 | } 56 | } 57 | 58 | return null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/php/SAccounts/Visitor/NodeSaver.php: -------------------------------------------------------------------------------- 1 | chartId = $chartId; 36 | $this->db = $db; 37 | $this->prnt = null; 38 | } 39 | 40 | /** 41 | * @param NodeInterface $node 42 | * 43 | * @return Account|null 44 | */ 45 | public function visit(NodeInterface $node): ?Account 46 | { 47 | /** @var Account $currAc */ 48 | $currAc = $node->getValue(); 49 | 50 | $nominal = $currAc->getNominal()->get(); 51 | $type = $currAc->getType()->getKey(); 52 | $name = $currAc->getName(); 53 | $prntNominal = ($node->isRoot() ? '' : $node->getParent()->getValue()->getNominal()->get()); 54 | 55 | $this->db->query( 56 | "call sa_sp_add_ledger({$this->chartId}, '{$nominal}', '{$type}', '{$name}', '{$prntNominal}')", 57 | Adapter::QUERY_MODE_EXECUTE 58 | ); 59 | 60 | foreach ($node->getChildren() as $child) { 61 | /** @noinspection PhpVoidFunctionResultUsedInspection */ 62 | $child->accept($this); 63 | } 64 | 65 | return $currAc; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/php/SAccounts/Zend/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | 0) THEN 39 | SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1859, MESSAGE_TEXT = _utf8'Chart already has root account'; 40 | END IF; 41 | END IF; 42 | 43 | SET prntId = 0; 44 | IF (prntNominal != '') THEN 45 | SELECT id from sa_coa_ledger l 46 | WHERE l.nominal = prntNominal 47 | AND l.chartId = chartInternalId 48 | INTO prntId; 49 | IF (prntId = 0) THEN 50 | SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1107, MESSAGE_TEXT = _utf8'Invalid parent account nominal'; 51 | END IF; 52 | END IF; 53 | 54 | INSERT INTO sa_coa_ledger (`prntId`, `chartId`, `nominal`, `type`, `name`) 55 | VALUES (prntId, chartInternalId, nominal, type, name); 56 | END; 57 | // 58 | DROP PROCEDURE IF EXISTS sa_sp_del_ledger// 59 | CREATE DEFINER = CURRENT_USER PROCEDURE 60 | sa_sp_del_ledger( 61 | chartId INT(10) UNSIGNED, 62 | nominal VARCHAR(10) 63 | ) 64 | MODIFIES SQL DATA DETERMINISTIC 65 | BEGIN 66 | DECLARE accId INT(10) UNSIGNED; 67 | DECLARE accDr INT(10) UNSIGNED; 68 | DECLARE accCr INT(10) UNSIGNED; 69 | SELECT id, acDr, acCr FROM sa_coa_ledger l 70 | WHERE l.nominal = nominal 71 | AND l.chartId = chartId 72 | INTO accId, accDr, accCr; 73 | IF (accDr > 0 OR accCr > 0) THEN 74 | SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 2000, MESSAGE_TEXT = _utf8'Account balance is non zero'; 75 | END IF; 76 | 77 | DELETE FROM sa_coa_ledger 78 | WHERE prntId = accId; 79 | 80 | DELETE FROM sa_coa_ledger 81 | WHERE id = accId; 82 | END; 83 | // 84 | DROP FUNCTION IF EXISTS sa_fu_add_txn; 85 | CREATE DEFINER = CURRENT_USER FUNCTION 86 | sa_fu_add_txn( 87 | chartId INT(10) UNSIGNED, 88 | note TEXT, 89 | date DATETIME, 90 | src VARCHAR(6), 91 | ref INT(10) UNSIGNED, 92 | arNominals TEXT, 93 | arAmounts TEXT, 94 | arTxnType TEXT 95 | ) 96 | RETURNS INT(10) UNSIGNED 97 | MODIFIES SQL DATA DETERMINISTIC 98 | BEGIN 99 | DECLARE jrnId INT(10) UNSIGNED; 100 | DECLARE numInArray INT; 101 | 102 | SET date = IFNULL(date, CURRENT_TIMESTAMP); 103 | 104 | INSERT INTO sa_journal (`chartId`, `note`, `date`, `src`, `ref`) 105 | VALUES (chartId, note, date, src, ref); 106 | SELECT last_insert_id() INTO jrnId; 107 | 108 | SET numInArray = char_length(arNominals) - char_length(replace(arNominals, ',', '')) + 1; 109 | 110 | SET @x = numInArray; 111 | REPEAT 112 | SET @txnType = substring_index(substring_index(arTxnType, ',', @x),',',-1); 113 | SET @nominal = substring_index(substring_index(arNominals, ',', @x),',',-1); 114 | SET @drAmount = 0; 115 | SET @crAmount = 0; 116 | IF @txnType = 'dr' THEN 117 | SET @drAmount = substring_index(substring_index(arAmounts, ',', @x),',',-1); 118 | ELSE 119 | SET @crAmount = substring_index(substring_index(arAmounts, ',', @x),',',-1); 120 | END IF; 121 | 122 | INSERT INTO sa_journal_entry(`jrnId`, `nominal`, `acDr`, `acCr`) 123 | VALUE (jrnId, @nominal, @drAmount, @crAmount); 124 | SET @x = @x - 1; 125 | UNTIL @x = 0 END REPEAT; 126 | 127 | RETURN jrnId; 128 | END; 129 | // 130 | 131 | DROP PROCEDURE IF EXISTS sa_sp_get_tree// 132 | CREATE DEFINER = CURRENT_USER PROCEDURE 133 | sa_sp_get_tree( 134 | chartId INT(10) UNSIGNED 135 | ) 136 | READS SQL DATA 137 | BEGIN 138 | SELECT origid, destid, l3.nominal, l3.name, l3.type, l3.acDr, l3.acCr from sa_coa_graph 139 | LEFT JOIN sa_coa_ledger as l1 ON origid = l1.id 140 | LEFT JOIN sa_coa_ledger as l3 ON destid = l3.id 141 | WHERE l1.chartId = chartId 142 | UNION 143 | SELECT 0 as origid, min(l2.id) as destid, l2.nominal, l2.name, l2.type, l2.acDr, l2.acCr 144 | FROM sa_coa_ledger as l2 145 | WHERE l2.chartId = chartId 146 | ORDER BY origid, destid; 147 | END; 148 | // 149 | DELIMITER ; 150 | 151 | -------------------------------------------------------------------------------- /src/sql/mariadb/build-tables.sql: -------------------------------------------------------------------------------- 1 | # Build script for simple accounts database - MariaDb/OQGraph Variant 2 | # Copyright, 2018, Ashley Kitson, UK 3 | # License: BSD-3-Clause, see License.md 4 | 5 | DROP TABLE IF EXISTS sa_coa_ledger; 6 | DROP TABLE IF EXISTS sa_coa_graph; 7 | DROP TABLE IF EXISTS sa_journal_entry; 8 | DROP TABLE IF EXISTS sa_journal; 9 | DROP TABLE IF EXISTS sa_coa; 10 | DROP TABLE IF EXISTS sa_ac_type; 11 | 12 | CREATE TABLE `sa_ac_type` ( 13 | `type` varchar(10) NOT NULL COMMENT 'External value of account type', 14 | `value` smallint(6) NOT NULL COMMENT 'Internal value of account type', 15 | PRIMARY KEY (`type`) 16 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Account type enumeration'; 17 | 18 | # Whilst the account type name is really important 19 | # the value is chosen deliberately to provide a BITWISE 20 | # capability 21 | # The main code uses a subset of this functionality 22 | # It is left to you to use it elsewhere 23 | # The values for each account type are chosen for their bit values 24 | # Don't fuck with them 25 | # 00000001011 26 | INSERT INTO sa_ac_type (type, value) VALUES ('ASSET', 11); 27 | # 00000011011 28 | INSERT INTO sa_ac_type (type, value) VALUES ('BANK', 27); 29 | # 00000000101 30 | INSERT INTO sa_ac_type (type, value) VALUES ('CR', 5); 31 | # 00000101100 32 | INSERT INTO sa_ac_type (type, value) VALUES ('CUSTOMER', 44); 33 | # 00000000011 34 | INSERT INTO sa_ac_type (type, value) VALUES ('DR', 3); 35 | # 00000000000 36 | INSERT INTO sa_ac_type (type, value) VALUES ('DUMMY', 0); 37 | # 01010000101 38 | INSERT INTO sa_ac_type (type, value) VALUES ('EQUITY', 645); 39 | # 00001001101 40 | INSERT INTO sa_ac_type (type, value) VALUES ('EXPENSE', 77); 41 | # 00110000101 42 | INSERT INTO sa_ac_type (type, value) VALUES ('INCOME', 389); 43 | # 00010000101 44 | INSERT INTO sa_ac_type (type, value) VALUES ('LIABILITY', 133); 45 | # 00000000001 46 | INSERT INTO sa_ac_type (type, value) VALUES ('REAL', 1); 47 | # 10010000101 48 | INSERT INTO sa_ac_type (type, value) VALUES ('SUPPLIER', 1157); 49 | 50 | CREATE TABLE `sa_coa` ( 51 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id of chart', 52 | `name` varchar(20) NOT NULL COMMENT 'name of chart', 53 | PRIMARY KEY (`id`), 54 | UNIQUE KEY `sa_coa_name_uindex` (`name`) 55 | ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8 COMMENT='A Chart of Account'; 56 | 57 | CREATE TABLE `sa_coa_ledger` ( 58 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'internal ledger id', 59 | `prntId` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'internal id of parent account', 60 | `chartId` int(10) unsigned DEFAULT NULL COMMENT 'id of chart that this account belongs to', 61 | `nominal` char(10) NOT NULL COMMENT 'nominal id for this account', 62 | `type` varchar(10) DEFAULT NULL COMMENT 'type of account', 63 | `name` varchar(30) NOT NULL COMMENT 'name of account', 64 | `acDr` bigint(20) NOT NULL DEFAULT '0' COMMENT 'debit amount', 65 | `acCr` bigint(20) NOT NULL DEFAULT '0'COMMENT 'credit amount', 66 | PRIMARY KEY (`id`), 67 | UNIQUE KEY `sa_coa_ledger_chartId_nominal_index` (`chartId`,`nominal`), 68 | KEY `sa_coa_ledger_sa_ac_type_type_fk` (`type`), 69 | KEY `sa_coa_ledger_sa_coa_fk` (`chartId`), 70 | CONSTRAINT `sa_coa_ledger_sa_ac_type_type_fk` FOREIGN KEY (`type`) REFERENCES `sa_ac_type` (`type`) ON DELETE CASCADE, 71 | CONSTRAINT `sa_coa_ledger_sa_coa_fk` FOREIGN KEY (`chartId`) REFERENCES `sa_coa` (`id`) ON DELETE CASCADE 72 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Chart of Account structure'; 73 | 74 | CREATE TABLE `sa_coa_graph` ( 75 | `latch` varchar(32) DEFAULT NULL, 76 | `origid` bigint(20) unsigned DEFAULT NULL, 77 | `destid` bigint(20) unsigned DEFAULT NULL, 78 | `weight` double DEFAULT NULL, 79 | `seq` bigint(20) unsigned DEFAULT NULL, 80 | `linkid` bigint(20) unsigned DEFAULT NULL, 81 | KEY `latch` (`latch`,`origid`,`destid`) USING HASH, 82 | KEY `latch_2` (`latch`,`destid`,`origid`) USING HASH 83 | ) ENGINE=OQGRAPH 84 | DEFAULT CHARSET=utf8 `data_table`='sa_coa_ledger' `origid`='prntId' `destid`='id' 85 | COMMENT 'oqgraph linking table'; 86 | 87 | CREATE TABLE `sa_journal` ( 88 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'internal id of the journal', 89 | `chartId` int(10) unsigned NOT NULL COMMENT 'the chart to which this journal belongs', 90 | `note` text COMMENT 'a note for the journal entry', 91 | `date` datetime DEFAULT CURRENT_TIMESTAMP COMMENT 'timestamp for this journal', 92 | `src` VARCHAR(6) COMMENT 'user defined source of journal', 93 | `ref` INT(10) UNSIGNED COMMENT 'user defined reference to this journal', 94 | PRIMARY KEY (`id`), 95 | KEY `sa_journal_sa_coa_id_fk` (`chartId`), 96 | KEY `sa_journal_external_reference` (`src`, `ref`), 97 | CONSTRAINT `sa_journal_sa_coa_id_fk` FOREIGN KEY (`chartId`) REFERENCES `sa_coa` (`id`) ON DELETE CASCADE 98 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Txn Journal Header'; 99 | 100 | CREATE TABLE `sa_journal_entry` ( 101 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'internal id for entry', 102 | `jrnId` int(10) unsigned DEFAULT NULL COMMENT 'id if journal that this entry belongs to', 103 | `nominal` varchar(10) NOT NULL COMMENT 'nominal code for entry', 104 | `acDr` bigint(20) DEFAULT '0' COMMENT 'debit amount for entry', 105 | `acCr` bigint(20) DEFAULT '0' COMMENT 'credit amount for entry', 106 | PRIMARY KEY (`id`), 107 | KEY `sa_journal_entry_sa_org_id_fk` (`jrnId`), 108 | CONSTRAINT `sa_journal_entry_sa_jrn_id_fk` FOREIGN KEY (`jrnId`) REFERENCES `sa_journal` (`id`) ON DELETE CASCADE 109 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Txn Journal Entry'; 110 | -------------------------------------------------------------------------------- /src/sql/mariadb/build-triggers.sql: -------------------------------------------------------------------------------- 1 | # Build script for simple accounts database - MariaDb/OQGraph Variant 2 | # Copyright, 2018, Ashley Kitson, UK 3 | # License: BSD-3-Clause, see License.md 4 | 5 | DELIMITER // 6 | DROP TRIGGER IF EXISTS sp_tr_jrn_entry_updt// 7 | CREATE DEFINER = CURRENT_USER TRIGGER sp_tr_jrn_entry_updt 8 | AFTER INSERT ON sa_journal_entry FOR EACH ROW 9 | BEGIN 10 | 11 | SELECT l.id FROM sa_coa_ledger l 12 | LEFT JOIN sa_journal j ON j.chartId = l.chartId 13 | WHERE l.nominal = NEW.nominal 14 | AND j.id = NEW.jrnId 15 | INTO @acId; 16 | 17 | UPDATE sa_coa_ledger l 18 | SET l.acDr = l.acDr + NEW.acDr, 19 | l.acCr = l.acCr + NEW.acCr 20 | WHERE id IN ( 21 | SELECT linkid 22 | FROM sa_coa_graph 23 | WHERE latch = '1' 24 | AND linkid > 0 25 | AND destid = @acId 26 | ); 27 | END; 28 | // 29 | DELIMITER ; 30 | -------------------------------------------------------------------------------- /src/sql/mariadb/drop-procs.sql: -------------------------------------------------------------------------------- 1 | DROP PROCEDURE IF EXISTS sa_sp_add_ledger; 2 | DROP PROCEDURE IF EXISTS sa_sp_del_ledger; 3 | DROP FUNCTION IF EXISTS sa_fu_add_chart; 4 | DROP FUNCTION IF EXISTS sa_fu_add_txn; 5 | DROP PROCEDURE IF EXISTS sa_sp_get_tree; -------------------------------------------------------------------------------- /src/sql/mariadb/drop-tables.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS sa_coa_ledger; 2 | DROP TABLE IF EXISTS sa_coa_graph; 3 | DROP TABLE IF EXISTS sa_journal_entry; 4 | DROP TABLE IF EXISTS sa_journal; 5 | DROP TABLE IF EXISTS sa_ac_type; 6 | DROP TABLE IF EXISTS sa_coa; 7 | -------------------------------------------------------------------------------- /src/sql/mysql/build-procs.sql: -------------------------------------------------------------------------------- 1 | # Build script for simple accounts database - MySql Variant 2 | # Copyright, 2018, Ashley Kitson, UK 3 | # License: BSD-3-Clause, see License.md 4 | DELIMITER // 5 | 6 | DROP FUNCTION IF EXISTS sa_fu_add_chart// 7 | CREATE DEFINER = CURRENT_USER FUNCTION 8 | sa_fu_add_chart( 9 | name VARCHAR(20) 10 | ) 11 | RETURNS INT(10) UNSIGNED 12 | MODIFIES SQL DATA DETERMINISTIC 13 | BEGIN 14 | INSERT INTO sa_coa (`name`) VALUES (name); 15 | RETURN last_insert_id(); 16 | END; 17 | // 18 | 19 | DROP PROCEDURE IF EXISTS sa_sp_add_ledger// 20 | CREATE DEFINER = CURRENT_USER PROCEDURE 21 | sa_sp_add_ledger( 22 | chartInternalId INT(10) UNSIGNED, 23 | nominal VARCHAR(10), 24 | type VARCHAR(10), 25 | name VARCHAR(30), 26 | prntNominal VARCHAR(10) 27 | ) 28 | MODIFIES SQL DATA DETERMINISTIC 29 | BEGIN 30 | DECLARE vPrntId INT(10) UNSIGNED; 31 | DECLARE cntPrnts INT; 32 | DECLARE rightChildId INT; 33 | DECLARE myLeft INT; 34 | DECLARE myRight INT; 35 | 36 | # check to see if we already have a root account 37 | IF (prntNominal = '') 38 | THEN 39 | SELECT count(id) 40 | FROM sa_coa_ledger l 41 | WHERE l.prntId = 0 42 | AND l.chartId = chartInternalId 43 | INTO cntPrnts; 44 | 45 | IF (cntPrnts > 0) 46 | THEN 47 | SIGNAL SQLSTATE '45000' 48 | SET MYSQL_ERRNO = 1859, MESSAGE_TEXT = _utf8'Chart already has root account'; 49 | END IF; 50 | END IF; 51 | 52 | SET vPrntId := 0; 53 | # Find the parent ledger id if the nominal id is not empty 54 | # as id cannot be zero, return zero if not found 55 | IF (prntNominal != '') 56 | THEN 57 | SELECT IFNULL((SELECT id 58 | from sa_coa_ledger l 59 | WHERE l.nominal = prntNominal 60 | AND l.chartId = chartInternalId), 0) 61 | INTO vPrntId; 62 | 63 | IF (vPrntId = 0) 64 | THEN 65 | SIGNAL SQLSTATE '45000' 66 | SET MYSQL_ERRNO = 1107, MESSAGE_TEXT = _utf8'Invalid parent account nominal'; 67 | END IF; 68 | END IF; 69 | 70 | IF (vPrntId = 0) 71 | THEN 72 | # We are inserting the root node - easy case 73 | INSERT INTO sa_coa_ledger (`prntId`, `lft`, `rgt`, `chartId`, `nominal`, `type`, `name`) 74 | VALUES (0, 1, 2, chartInternalId, nominal, type, name); 75 | ELSE 76 | # Does the parent have any children? 77 | SELECT IFNULL((SELECT max(id) 78 | FROM sa_coa_ledger 79 | WHERE prntId = vPrntId), 0) 80 | INTO rightChildId; 81 | 82 | IF (rightChildId = 0) 83 | THEN 84 | # no children 85 | SELECT lft 86 | FROM sa_coa_ledger 87 | WHERE id = vPrntId 88 | INTO myLeft; 89 | 90 | UPDATE sa_coa_ledger 91 | SET rgt = rgt + 2 92 | WHERE rgt > myLeft; 93 | 94 | UPDATE sa_coa_ledger 95 | SET lft = lft + 2 96 | WHERE lft > myLeft; 97 | 98 | INSERT INTO sa_coa_ledger (`prntId`, `lft`, `rgt`, `chartId`, `nominal`, `type`, `name`) 99 | VALUES 100 | (vPrntId, myLeft + 1, myLeft + 2, chartInternalId, nominal, type, name); 101 | ELSE 102 | # has children, add to right of last child 103 | SELECT rgt 104 | FROM sa_coa_ledger 105 | WHERE id = rightChildId 106 | INTO myRight; 107 | 108 | UPDATE sa_coa_ledger 109 | SET rgt = rgt + 2 110 | WHERE rgt > myRight; 111 | 112 | UPDATE sa_coa_ledger 113 | SET lft = lft + 2 114 | WHERE lft > myRight; 115 | 116 | INSERT INTO sa_coa_ledger (`prntId`, `lft`, `rgt`, `chartId`, `nominal`, `type`, `name`) 117 | VALUES 118 | (vPrntId, myRight + 1, myRight + 2, chartInternalId, nominal, type, name); 119 | END IF; 120 | END IF; 121 | END; 122 | // 123 | 124 | DROP PROCEDURE IF EXISTS sa_sp_del_ledger// 125 | CREATE DEFINER = CURRENT_USER PROCEDURE 126 | sa_sp_del_ledger( 127 | chartId INT(10) UNSIGNED, 128 | nominal VARCHAR(10) 129 | ) 130 | MODIFIES SQL DATA DETERMINISTIC 131 | BEGIN 132 | DECLARE accId INT(10) UNSIGNED; 133 | DECLARE accDr INT(10) UNSIGNED; 134 | DECLARE accCr INT(10) UNSIGNED; 135 | SELECT 136 | id, 137 | acDr, 138 | acCr 139 | FROM sa_coa_ledger l 140 | WHERE l.nominal = nominal 141 | AND l.chartId = chartId 142 | INTO accId, accDr, accCr; 143 | 144 | IF (accDr > 0 OR accCr > 0) 145 | THEN 146 | SIGNAL SQLSTATE '45000' 147 | SET MYSQL_ERRNO = 2000, MESSAGE_TEXT = _utf8'Account balance is non zero'; 148 | END IF; 149 | 150 | DELETE FROM sa_coa_ledger 151 | WHERE prntId = accId; 152 | 153 | DELETE FROM sa_coa_ledger 154 | WHERE id = accId; 155 | END; 156 | // 157 | 158 | DROP FUNCTION IF EXISTS sa_fu_add_txn// 159 | CREATE DEFINER = CURRENT_USER FUNCTION 160 | sa_fu_add_txn( 161 | chartId INT(10) UNSIGNED, 162 | note TEXT, 163 | date DATETIME, 164 | src VARCHAR(6), 165 | ref INT(10) UNSIGNED, 166 | arNominals TEXT, 167 | arAmounts TEXT, 168 | arTxnType TEXT 169 | ) 170 | RETURNS INT(10) UNSIGNED 171 | MODIFIES SQL DATA DETERMINISTIC 172 | BEGIN 173 | DECLARE jrnId INT(10) UNSIGNED; 174 | DECLARE numInArray INT; 175 | 176 | SET date = IFNULL(date, CURRENT_TIMESTAMP); 177 | 178 | INSERT INTO sa_journal (`chartId`, `note`, `date`, `src`, `ref`) 179 | VALUES (chartId, note, date, src, ref); 180 | 181 | SELECT last_insert_id() 182 | INTO jrnId; 183 | 184 | SET numInArray = 185 | char_length(arNominals) - char_length(replace(arNominals, ',', '')) + 1; 186 | 187 | SET @x = numInArray; 188 | REPEAT 189 | SET @txnType = substring_index(substring_index(arTxnType, ',', @x), ',', -1); 190 | SET @nominal = substring_index(substring_index(arNominals, ',', @x), ',', -1); 191 | SET @drAmount = 0; 192 | SET @crAmount = 0; 193 | IF @txnType = 'dr' 194 | THEN 195 | SET @drAmount = substring_index(substring_index(arAmounts, ',', @x), ',', 196 | -1); 197 | ELSE 198 | SET @crAmount = substring_index(substring_index(arAmounts, ',', @x), ',', 199 | -1); 200 | END IF; 201 | 202 | INSERT INTO sa_journal_entry (`jrnId`, `nominal`, `acDr`, `acCr`) 203 | VALUE (jrnId, @nominal, @drAmount, @crAmount); 204 | SET @x = @x - 1; 205 | UNTIL @x = 0 END REPEAT; 206 | 207 | RETURN jrnId; 208 | END; 209 | // 210 | 211 | DROP PROCEDURE IF EXISTS sa_sp_get_tree// 212 | CREATE DEFINER = CURRENT_USER PROCEDURE 213 | sa_sp_get_tree( 214 | chartId INT(10) UNSIGNED 215 | ) 216 | READS SQL DATA 217 | BEGIN 218 | SELECT 219 | prntId as origid, 220 | id as destid, 221 | nominal, 222 | name, 223 | type, 224 | acDr, 225 | acCr 226 | FROM sa_coa_ledger 227 | WHERE `chartId` = chartId 228 | ORDER BY origid, destid; 229 | END; 230 | // 231 | DELIMITER ; 232 | 233 | -------------------------------------------------------------------------------- /src/sql/mysql/build-tables.sql: -------------------------------------------------------------------------------- 1 | # Build script for simple accounts database - MySql variant 2 | # Copyright, 2018, Ashley Kitson, UK 3 | # License: BSD-3-Clause, see License.md 4 | 5 | DROP TABLE IF EXISTS sa_coa_ledger; 6 | DROP TABLE IF EXISTS sa_journal_entry; 7 | DROP TABLE IF EXISTS sa_journal; 8 | DROP TABLE IF EXISTS sa_coa; 9 | DROP TABLE IF EXISTS sa_ac_type; 10 | 11 | CREATE TABLE `sa_ac_type` ( 12 | `type` varchar(10) NOT NULL COMMENT 'External value of account type', 13 | `value` smallint(6) NOT NULL COMMENT 'Internal value of account type', 14 | PRIMARY KEY (`type`) 15 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Account type enumeration'; 16 | 17 | # Whilst the account type name is really important 18 | # the value is chosen deliberately to provide a BITWISE 19 | # capability 20 | # The main code uses a subset of this functionality 21 | # It is left to you to use it elsewhere 22 | # The values for each account type are chosen for their bit values 23 | # Don't fuck with them 24 | # 00000001011 25 | INSERT INTO sa_ac_type (type, value) VALUES ('ASSET', 11); 26 | # 00000011011 27 | INSERT INTO sa_ac_type (type, value) VALUES ('BANK', 27); 28 | # 00000000101 29 | INSERT INTO sa_ac_type (type, value) VALUES ('CR', 5); 30 | # 00000101100 31 | INSERT INTO sa_ac_type (type, value) VALUES ('CUSTOMER', 44); 32 | # 00000000011 33 | INSERT INTO sa_ac_type (type, value) VALUES ('DR', 3); 34 | # 00000000000 35 | INSERT INTO sa_ac_type (type, value) VALUES ('DUMMY', 0); 36 | # 01010000101 37 | INSERT INTO sa_ac_type (type, value) VALUES ('EQUITY', 645); 38 | # 00001001101 39 | INSERT INTO sa_ac_type (type, value) VALUES ('EXPENSE', 77); 40 | # 00110000101 41 | INSERT INTO sa_ac_type (type, value) VALUES ('INCOME', 389); 42 | # 00010000101 43 | INSERT INTO sa_ac_type (type, value) VALUES ('LIABILITY', 133); 44 | # 00000000001 45 | INSERT INTO sa_ac_type (type, value) VALUES ('REAL', 1); 46 | # 10010000101 47 | INSERT INTO sa_ac_type (type, value) VALUES ('SUPPLIER', 1157); 48 | 49 | CREATE TABLE `sa_coa` ( 50 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id of chart', 51 | `name` varchar(20) NOT NULL COMMENT 'name of chart', 52 | PRIMARY KEY (`id`), 53 | UNIQUE KEY `sa_coa_name_uindex` (`name`) 54 | ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8 COMMENT='A Chart of Account'; 55 | 56 | CREATE TABLE `sa_coa_ledger` ( 57 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'internal ledger id', 58 | `prntId` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'parent node internal id', 59 | `lft` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'left node internal id', 60 | `rgt` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'node node internal id', 61 | `chartId` int(10) unsigned DEFAULT NULL COMMENT 'id of chart that this account belongs to', 62 | `nominal` char(10) NOT NULL COMMENT 'nominal id for this account', 63 | `type` varchar(10) DEFAULT NULL COMMENT 'type of account', 64 | `name` varchar(30) NOT NULL COMMENT 'name of account', 65 | `acDr` bigint(20) NOT NULL DEFAULT '0' COMMENT 'debit amount', 66 | `acCr` bigint(20) NOT NULL DEFAULT '0'COMMENT 'credit amount', 67 | PRIMARY KEY (`id`), 68 | UNIQUE KEY `sa_coa_ledger_chartId_nominal_index` (`chartId`,`nominal`), 69 | KEY `sa_coa_ledger_sa_ac_type_type_fk` (`type`), 70 | KEY `sa_coa_ledger_sa_coa_fk` (`chartId`), 71 | INDEX `sa_coa_ledger_lft_idx` (`lft`), 72 | INDEX `sa_coa_ledger_rgt_idx` (`rgt`), 73 | CONSTRAINT `sa_coa_ledger_sa_ac_type_type_fk` FOREIGN KEY (`type`) REFERENCES `sa_ac_type` (`type`) ON DELETE CASCADE, 74 | CONSTRAINT `sa_coa_ledger_sa_coa_fk` FOREIGN KEY (`chartId`) REFERENCES `sa_coa` (`id`) ON DELETE CASCADE 75 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Chart of Account structure'; 76 | 77 | CREATE TABLE `sa_journal` ( 78 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'internal id of the journal', 79 | `chartId` int(10) unsigned NOT NULL COMMENT 'the chart to which this journal belongs', 80 | `note` text COMMENT 'a note for the journal entry', 81 | `date` datetime DEFAULT CURRENT_TIMESTAMP COMMENT 'timestamp for this journal', 82 | `src` VARCHAR(6) COMMENT 'user defined source of journal', 83 | `ref` INT(10) UNSIGNED COMMENT 'user defined reference to this journal', 84 | PRIMARY KEY (`id`), 85 | KEY `sa_journal_sa_coa_id_fk` (`chartId`), 86 | KEY `sa_journal_external_reference` (`src`, `ref`), 87 | CONSTRAINT `sa_journal_sa_coa_id_fk` FOREIGN KEY (`chartId`) REFERENCES `sa_coa` (`id`) ON DELETE CASCADE 88 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Txn Journal Header'; 89 | 90 | CREATE TABLE `sa_journal_entry` ( 91 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'internal id for entry', 92 | `jrnId` int(10) unsigned DEFAULT NULL COMMENT 'id if journal that this entry belongs to', 93 | `nominal` varchar(10) NOT NULL COMMENT 'nominal code for entry', 94 | `acDr` bigint(20) DEFAULT '0' COMMENT 'debit amount for entry', 95 | `acCr` bigint(20) DEFAULT '0' COMMENT 'credit amount for entry', 96 | PRIMARY KEY (`id`), 97 | KEY `sa_journal_entry_sa_org_id_fk` (`jrnId`), 98 | CONSTRAINT `sa_journal_entry_sa_jrn_id_fk` FOREIGN KEY (`jrnId`) REFERENCES `sa_journal` (`id`) ON DELETE CASCADE 99 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Txn Journal Entry'; 100 | -------------------------------------------------------------------------------- /src/sql/mysql/build-triggers.sql: -------------------------------------------------------------------------------- 1 | # Build script for simple accounts database - Mysql Variant 2 | # Copyright, 2018, Ashley Kitson, UK 3 | # License: BSD-3-Clause, see License.md 4 | 5 | DELIMITER // 6 | DROP TRIGGER IF EXISTS sp_tr_jrn_entry_updt// 7 | CREATE DEFINER = CURRENT_USER TRIGGER sp_tr_jrn_entry_updt 8 | AFTER INSERT ON sa_journal_entry FOR EACH ROW 9 | BEGIN 10 | 11 | # get the internal ledger id 12 | SELECT l.id FROM sa_coa_ledger l 13 | LEFT JOIN sa_journal j ON j.chartId = l.chartId 14 | WHERE l.nominal = NEW.nominal 15 | AND j.id = NEW.jrnId 16 | INTO @acId; 17 | 18 | # create a concatenated string of parent ids as 19 | # creating temporary tables to hold parent ledger ids 20 | # borks, and you can't select from a table whilst updating 21 | 22 | SELECT GROUP_CONCAT(DISTINCT parent.id SEPARATOR ',') 23 | FROM sa_coa_ledger AS node, 24 | sa_coa_ledger AS parent 25 | WHERE node.lft BETWEEN parent.lft AND parent.rgt 26 | AND node.id = @acId 27 | GROUP BY node.id 28 | INTO @parents; 29 | 30 | SET @numInArray = 31 | char_length(@parents) - char_length(replace(@parents, ',', '')) + 1; 32 | 33 | # update the parent ledger accounts 34 | WHILE (@numInArray > 0) 35 | DO 36 | SET @prntId = substring_index(substring_index(@parents, ',', @numInArray), ',', -1); 37 | UPDATE sa_coa_ledger l 38 | SET l.acDr = l.acDr + NEW.acDr, 39 | l.acCr = l.acCr + NEW.acCr 40 | WHERE id = @prntId; 41 | SET @numInArray = @numInArray - 1; 42 | END WHILE; 43 | END; 44 | // 45 | DELIMITER ; 46 | -------------------------------------------------------------------------------- /src/sql/mysql/drop-procs.sql: -------------------------------------------------------------------------------- 1 | DROP PROCEDURE IF EXISTS sa_sp_add_ledger; 2 | DROP PROCEDURE IF EXISTS sa_sp_del_ledger; 3 | DROP FUNCTION IF EXISTS sa_fu_add_chart; 4 | DROP FUNCTION IF EXISTS sa_fu_add_txn; 5 | DROP PROCEDURE IF EXISTS sa_sp_get_tree; -------------------------------------------------------------------------------- /src/sql/mysql/drop-tables.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS sa_coa_ledger; 2 | DROP TABLE IF EXISTS sa_journal_entry; 3 | DROP TABLE IF EXISTS sa_journal; 4 | DROP TABLE IF EXISTS sa_ac_type; 5 | DROP TABLE IF EXISTS sa_coa; 6 | -------------------------------------------------------------------------------- /src/xml/personal.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /src/xsd/chart-definition.xsd: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/php/.gitignore: -------------------------------------------------------------------------------- 1 | local* 2 | .phpunit.result.cache -------------------------------------------------------------------------------- /test/php/SAccounts/AccountTest.php: -------------------------------------------------------------------------------- 1 | sut = new Account( 31 | new Nominal('9999'), 32 | $acType, 33 | 'foo', 34 | 0, 35 | 0 36 | ); 37 | $this->assertInstanceOf(Account::class, $this->sut); 38 | } 39 | 40 | public function validAccountTypes() 41 | { 42 | return [ 43 | [AccountType::DUMMY()], 44 | [AccountType::DR()], 45 | [AccountType::CR()], 46 | [AccountType::ASSET()], 47 | [AccountType::LIABILITY()], 48 | [AccountType::BANK()], 49 | [AccountType::CUSTOMER()], 50 | [AccountType::EQUITY()], 51 | [AccountType::EXPENSE()], 52 | [AccountType::INCOME()], 53 | [AccountType::REAL()], 54 | [AccountType::SUPPLIER()], 55 | ]; 56 | } 57 | 58 | /** 59 | * @dataProvider accountTypesThatHaveBalance 60 | */ 61 | public function testYouCanGetABalanceForAccountTypesThatSupportIt(AccountType $acType, $dr, $cr) 62 | { 63 | $this->sut = new Account( 64 | new Nominal('9999'), 65 | $acType, 66 | 'foo', 67 | $dr, 68 | $cr 69 | ); 70 | $this->assertIsInt($this->sut->getBalance()); 71 | $this->assertEquals(12, $this->sut->getBalance(), "wrong balance for: " . $acType->getKey()); 72 | } 73 | 74 | public function accountTypesThatHaveBalance() 75 | { 76 | return [ 77 | [AccountType::DR(), 12, 0], 78 | [AccountType::CR(), 0, 12], 79 | [AccountType::ASSET(), 12, 0], 80 | [AccountType::LIABILITY(), 0, 12], 81 | [AccountType::BANK(), 12, 0], 82 | [AccountType::CUSTOMER(), 12, 0], 83 | [AccountType::EQUITY(), 0, 12], 84 | [AccountType::EXPENSE(), 12, 0], 85 | [AccountType::INCOME(), 0, 12], 86 | [AccountType::SUPPLIER(), 0, 12], 87 | ]; 88 | } 89 | 90 | public function testGettingBalanceOfADummyAccountTypeWillThrowAnException() 91 | { 92 | $this->sut = new Account( 93 | new Nominal('9999'), 94 | AccountType::DUMMY(), 95 | 'foo', 96 | 0, 97 | 0 98 | ); 99 | $this->expectException(AccountsException::class); 100 | $this->sut->getBalance(); 101 | } 102 | 103 | public function testYouCanGetTheAccountNominalCode() 104 | { 105 | $this->sut = new Account( 106 | new Nominal('9999'), 107 | AccountType::DUMMY(), 108 | 'foo', 109 | 0, 110 | 0 111 | ); 112 | $this->assertEquals(new Nominal('9999'), $this->sut->getNominal()); 113 | } 114 | 115 | public function testYouCanGetTheAccountType() 116 | { 117 | $this->sut = new Account( 118 | new Nominal('9999'), 119 | AccountType::DUMMY(), 120 | 'foo', 121 | 0, 122 | 0 123 | ); 124 | $this->assertTrue(AccountType::DUMMY()->equals($this->sut->getType())); 125 | } 126 | 127 | public function testYouCanGetTheAccountName() 128 | { 129 | $this->sut = new Account( 130 | new Nominal('9999'), 131 | AccountType::DUMMY(), 132 | 'foo', 133 | 0, 134 | 0 135 | ); 136 | $this->assertEquals('foo', $this->sut->getName()); 137 | } 138 | 139 | public function testYouCanGetTheDebitAndCreditAmounts() 140 | { 141 | $this->sut = new Account( 142 | new Nominal('9999'), 143 | AccountType::DUMMY(), 144 | 'foo', 145 | 12, 146 | 20 147 | ); 148 | 149 | $this->assertEquals(12, $this->sut->dr()); 150 | $this->assertEquals(20, $this->sut->cr()); 151 | } 152 | } -------------------------------------------------------------------------------- /test/php/SAccounts/AccountTypeTest.php: -------------------------------------------------------------------------------- 1 | assertIsNumeric(AccountType::REAL); 20 | $this->assertIsNumeric(AccountType::DUMMY); 21 | $this->assertIsNumeric(AccountType::CR); 22 | $this->assertIsNumeric(AccountType::DR); 23 | $this->assertIsNumeric(AccountType::ASSET); 24 | $this->assertIsNumeric(AccountType::BANK); 25 | $this->assertIsNumeric(AccountType::CUSTOMER); 26 | $this->assertIsNumeric(AccountType::EXPENSE); 27 | $this->assertIsNumeric(AccountType::INCOME); 28 | $this->assertIsNumeric(AccountType::LIABILITY); 29 | $this->assertIsNumeric(AccountType::EQUITY); 30 | $this->assertIsNumeric(AccountType::SUPPLIER); 31 | } 32 | 33 | public function testCanGetValuesAsClassesUsingStaticMethods() 34 | { 35 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::REAL()); 36 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::CR()); 37 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::DR()); 38 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::ASSET()); 39 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::BANK()); 40 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::CUSTOMER()); 41 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::EXPENSE()); 42 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::INCOME()); 43 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::LIABILITY()); 44 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::EQUITY()); 45 | $this->assertInstanceOf('SAccounts\AccountType', AccountType::SUPPLIER()); 46 | } 47 | 48 | /** 49 | * @dataProvider validTitledata 50 | */ 51 | public function testCanGetADebitColumnTitleForAValidAccountType($acType, $titles) 52 | { 53 | $ac = new AccountType($acType); 54 | $this->assertEquals($titles['dr'], $ac->drTitle()); 55 | } 56 | 57 | public function testGetADebitColumnTitleWithInvalidAccountTypeWillThrowException() 58 | { 59 | $ac = new AccountType(0); 60 | $this->expectException(AccountsException::class); 61 | $ac->drTitle(); 62 | } 63 | 64 | public function testGetACreditColumnTitleWithInvalidAccountTypeWillThrowException() 65 | { 66 | $ac = new AccountType(0); 67 | $this->expectException(AccountsException::class); 68 | $ac->crTitle(); 69 | } 70 | 71 | /** 72 | * @dataProvider validTitledata 73 | */ 74 | public function testCanGetACreditColumnTitleForAValidAccountType($acType, $titles) 75 | { 76 | $ac = new AccountType($acType); 77 | $this->assertEquals($titles['cr'], $ac->crTitle()); 78 | } 79 | 80 | public function validTitleData() 81 | { 82 | return [ 83 | [AccountType::DR, ['dr'=>'Debit','cr'=>'Credit']], 84 | [AccountType::CR, ['dr'=>'Debit','cr'=>'Credit']], 85 | [AccountType::ASSET, ['dr'=>'Increase','cr'=>'Decrease']], 86 | [AccountType::BANK, ['dr'=>'Increase','cr'=>'Decrease']], 87 | [AccountType::CUSTOMER, ['dr'=>'Increase','cr'=>'Decrease']], 88 | [AccountType::EXPENSE, ['dr'=>'Expense','cr'=>'Refund']], 89 | [AccountType::INCOME, ['dr'=>'Charge','cr'=>'Income']], 90 | [AccountType::LIABILITY, ['dr'=>'Decrease','cr'=>'Increase']], 91 | [AccountType::EQUITY, ['dr'=>'Decrease','cr'=>'Increase']], 92 | [AccountType::SUPPLIER, ['dr'=>'Decrease','cr'=>'Increase']], 93 | ]; 94 | } 95 | 96 | /** 97 | * @dataProvider balanceData 98 | */ 99 | public function testWillGetCorrectBalanceForAllValidAccountTypes($acType, $dr, $cr, $result) 100 | { 101 | $ac = new AccountType($acType); 102 | $test = $ac->balance($dr, $cr); 103 | $this->assertEquals($result, $test); 104 | } 105 | 106 | public function balanceData() 107 | { 108 | return [ 109 | [AccountType::DR, 2, 1, 1], 110 | [AccountType::CR, 1, 2, 1], 111 | [AccountType::ASSET, 2, 1, 1], 112 | [AccountType::BANK, 2, 1, 1], 113 | [AccountType::CUSTOMER, 2, 1, 1], 114 | [AccountType::EXPENSE, 2, 1, 1], 115 | [AccountType::INCOME, 1, 2, 1], 116 | [AccountType::LIABILITY, 1, 2, 1], 117 | [AccountType::EQUITY, 1, 2, 1], 118 | [AccountType::SUPPLIER, 1, 2, 1], 119 | ]; 120 | } 121 | 122 | public function testGetABalanceWithInvalidAccountTypeWillThrowException() 123 | { 124 | $ac = new AccountType(AccountType::DUMMY); 125 | $this->expectException(AccountsException::class); 126 | $ac->balance(0, 0); 127 | } 128 | 129 | 130 | } 131 | -------------------------------------------------------------------------------- /test/php/SAccounts/AccountantTest.php: -------------------------------------------------------------------------------- 1 | 'Pdo_mysql', 40 | 'database' => DBNAME, 41 | 'username' => DBUID, 42 | 'password' => DBPWD, 43 | 'host' => DBHOST 44 | ]; 45 | $this->adapter = new Adapter($config); 46 | 47 | $this->sut = new Accountant($this->adapter); 48 | 49 | $this->adapter->query('delete from sa_coa', Adapter::QUERY_MODE_EXECUTE); 50 | } 51 | 52 | public function testAnAccountantCanCreateANewChartOfAccounts() 53 | { 54 | $this->assertIsInt($this->createChart()); 55 | } 56 | 57 | public function testAnAccountantCanFetchAChart() 58 | { 59 | $chartId = $this->createChart(); 60 | $chart = $this->sut->fetchChart(); 61 | $this->assertInstanceOf( 62 | 'SAccounts\Chart', 63 | $chart 64 | ); 65 | $this->assertEquals($chartId, $chart->id()); 66 | } 67 | 68 | public function testFetchingAChartWhenChartIdIsNotSetWillThrowAnException() 69 | { 70 | $this->expectException(AccountsException::class); 71 | $this->expectExceptionMessage('Chart id not set'); 72 | $this->sut->fetchChart(); 73 | } 74 | 75 | public function testYouCanWriteATransactionToAJournalAndUpdateAChart() 76 | { 77 | $chartId = $this->createChart(); 78 | $txn = new SplitTransaction('test', 'PUR', 10); 79 | $txn->addEntry(new Entry(new Nominal('7100'),1226, AccountType::DR())) 80 | ->addEntry(new Entry(new Nominal('2110'),1226, AccountType::CR())); 81 | 82 | $txnId = $this->sut->writeTransaction($txn); 83 | $journal = $this->adapter->query("select * from sa_journal where id = ?") 84 | ->execute([$txnId]) 85 | ->current(); 86 | $this->assertEquals($chartId, $journal['chartId']); 87 | $this->assertEquals('test', $journal['note']); 88 | $this->assertEquals(10, $journal['ref']); 89 | 90 | $entries = $this->adapter->query('select * from sa_journal_entry where jrnId = ?') 91 | ->execute([$txnId]); 92 | $this->assertEquals(2, $entries->count()); 93 | $entries = $entries->getResource()->fetchAll(\PDO::FETCH_ASSOC); 94 | 95 | $drEntry = array_filter( 96 | $entries, 97 | function($entry) { 98 | return $entry['nominal'] == '7100'; 99 | } 100 | ); 101 | 102 | $drEntry = array_pop($drEntry); 103 | $this->assertEquals($txnId, $drEntry['jrnId']); 104 | $this->assertEquals(1226, $drEntry['acDr']); 105 | $this->assertEquals(0, $drEntry['acCr']); 106 | 107 | $crEntry = array_filter( 108 | $entries, 109 | function($entry) { 110 | return $entry['nominal'] == '2110'; 111 | } 112 | ); 113 | $crEntry = array_pop($crEntry); 114 | $this->assertEquals($txnId, $crEntry['jrnId']); 115 | $this->assertEquals(1226, $crEntry['acCr']); 116 | $this->assertEquals(0, $crEntry['acDr']); 117 | 118 | $chart = $this->sut->fetchChart(); 119 | $this->assertEquals( 120 | 1226, 121 | $chart->getAccount(new Nominal('7100'))->getBalance() 122 | ); 123 | $this->assertEquals( 124 | 1226, 125 | $chart->getAccount(new Nominal('7000'))->getBalance() 126 | ); 127 | $this->assertEquals( 128 | 1226, 129 | $chart->getAccount(new Nominal('5000'))->dr() 130 | ); 131 | $this->assertEquals( 132 | -1226, 133 | $chart->getAccount(new Nominal('2110'))->getBalance() 134 | ); 135 | $this->assertEquals( 136 | -1226, 137 | $chart->getAccount(new Nominal('2100'))->getBalance() 138 | ); 139 | $this->assertEquals( 140 | -1226, 141 | $chart->getAccount(new Nominal('2000'))->getBalance() 142 | ); 143 | $this->assertEquals( 144 | 1226, 145 | $chart->getAccount(new Nominal('1000'))->cr() 146 | ); 147 | $this->assertEquals( 148 | 1226, 149 | $chart->getAccount(new Nominal('0000'))->dr() 150 | ); 151 | $this->assertEquals( 152 | 1226, 153 | $chart->getAccount(new Nominal('0000'))->cr() 154 | ); 155 | } 156 | 157 | public function testWritingATransactionWhenChartIdIsNotSetWillThrowAnException() 158 | { 159 | $txn = new SplitTransaction('test', 'PUR', 10); 160 | $txn->addEntry(new Entry(new Nominal('7100'),1226, AccountType::DR())) 161 | ->addEntry(new Entry(new Nominal('2110'),1226, AccountType::CR())); 162 | 163 | $this->expectException(AccountsException::class); 164 | $this->expectExceptionMessage('Chart id not set'); 165 | $this->sut->writeTransaction($txn); 166 | } 167 | 168 | public function testYouCanFetchAJournalTransactionByItsId() 169 | { 170 | $chartId = $this->createChart(); 171 | $txn = new SplitTransaction('test', 'PUR', 10); 172 | $txn->addEntry(new Entry(new Nominal('7100'),1226, AccountType::DR())) 173 | ->addEntry(new Entry(new Nominal('2110'),1226, AccountType::CR())); 174 | 175 | $txnId = $this->sut->writeTransaction($txn); 176 | 177 | $storedTxn = $this->sut->fetchTransaction($txnId); 178 | $this->assertInstanceOf(SplitTransaction::class, $storedTxn); 179 | 180 | $this->assertEquals(10, $storedTxn->getRef()); 181 | $this->assertEquals('test', $storedTxn->getNote()); 182 | $this->assertEquals($txnId, $storedTxn->getId()); 183 | 184 | /** @var Entry $drAc */ 185 | $drAc = $storedTxn->getEntry($storedTxn->getDrAc()[0]); 186 | $this->assertEquals('7100', $drAc->getId()->get()); 187 | $this->assertEquals(1226, $drAc->getAmount()); 188 | $this->assertTrue($drAc->getType()->equals(AccountType::DR())); 189 | /** @var Entry $crAc */ 190 | $crAc = $storedTxn->getEntry($storedTxn->getCrAc()[0]); 191 | $this->assertEquals('2110', $crAc->getId()->get()); 192 | $this->assertEquals(1226, $crAc->getAmount()); 193 | $this->assertTrue($crAc->getType()->equals(AccountType::CR())); 194 | } 195 | 196 | public function testYouCanAddAnAccountToAChart() 197 | { 198 | $this->createChart(); 199 | $nominal = new Nominal('7700'); 200 | $prntNominal = new Nominal(('7000')); 201 | $chartBefore = $this->sut->fetchChart(); 202 | 203 | $this->sut->addAccount($nominal, AccountType::EXPENSE(), 'foo', $prntNominal); 204 | $chartAfter = $this->sut->fetchChart(); 205 | 206 | $this->assertFalse($chartBefore->hasAccount($nominal)); 207 | $this->assertTrue($chartAfter->hasAccount($nominal)); 208 | } 209 | 210 | public function testAddingAnAccountToANonExistentParentWillThrowAnException() 211 | { 212 | $this->createChart(); 213 | $nominal = new Nominal('7700'); 214 | $prntNominal = new Nominal(('9999')); 215 | 216 | $this->expectException(DbException::class); 217 | $this->expectExceptionMessage('Invalid parent account nominal'); 218 | $this->sut->addAccount($nominal, AccountType::EXPENSE(), 'foo', $prntNominal); 219 | 220 | } 221 | 222 | public function testTryingToAddASecondRootAccountWillThrowAnException() 223 | { 224 | $this->createChart(); 225 | $nominal = new Nominal(('9999')); 226 | 227 | $this->expectException(DbException::class); 228 | $this->expectExceptionMessage('Chart already has root account'); 229 | $this->sut->addAccount($nominal, AccountType::EXPENSE(), 'foo'); 230 | } 231 | 232 | public function testYouCanDeleteAZeroBalanceAccount() 233 | { 234 | $this->createChart(); 235 | $nominal = new Nominal('3000'); 236 | $chartBefore = $this->sut->fetchChart(); 237 | $this->sut->delAccount($nominal); 238 | $chartAfter = $this->sut->fetchChart(); 239 | 240 | $this->assertTrue($chartBefore->hasAccount($nominal)); 241 | $this->assertFalse($chartAfter->hasAccount($nominal)); 242 | } 243 | 244 | public function testDeletingANonZeroBalanceAccountWillThrowAnException() 245 | { 246 | $this->createChart(); 247 | $nominal = new Nominal('3000'); 248 | $txn = new SimpleTransaction(new Nominal('2110'), new Nominal('3100'),1226); 249 | $this->sut->writeTransaction($txn); 250 | 251 | $this->expectException(DbException::class); 252 | $this->expectExceptionMessage('Account balance is non zero'); 253 | $this->sut->delAccount($nominal); 254 | } 255 | 256 | public function testYouCanFetchJournalEntriesForAnAccount() 257 | { 258 | $this->createChart(); 259 | $this->sut->writeTransaction( 260 | new SimpleTransaction( 261 | new Nominal('2110'), new Nominal('3100'),1226 262 | ) 263 | ); 264 | $this->sut->writeTransaction( 265 | new SimpleTransaction( 266 | new Nominal('3100'), new Nominal('2110'),1000 267 | ) 268 | ); 269 | $entries = $this->sut->fetchAccountJournals(new Nominal('3100')); 270 | 271 | $this->assertEquals(2, $entries->count()); 272 | } 273 | 274 | public function testFetchingJournalEntriesReturnsASetOfSplitTransactions() 275 | { 276 | $this->createChart(); 277 | $this->sut->writeTransaction( 278 | new SimpleTransaction( 279 | new Nominal('2110'), new Nominal('3100'),1226 280 | ) 281 | ); 282 | $entries = $this->sut->fetchAccountJournals(new Nominal('3100')); 283 | 284 | $this->assertInstanceOf(Set::class, $entries); 285 | $this->assertInstanceOf(SplitTransaction::class, $entries[0]); 286 | } 287 | 288 | public function testFetchingJournalEntriesForAnAggregateAccountWillReturnAnEmptySet() 289 | { 290 | $this->createChart(); 291 | $this->sut->writeTransaction( 292 | new SimpleTransaction( 293 | new Nominal('2110'), new Nominal('3100'),1226 294 | ) 295 | ); 296 | $entries = $this->sut->fetchAccountJournals(new Nominal('3000')); 297 | 298 | $this->assertEquals(0, $entries->count()); 299 | } 300 | 301 | protected function createChart() 302 | { 303 | $def = $this->getMockBuilder(ChartDefinition::class) 304 | ->disableOriginalConstructor() 305 | ->getMock(); 306 | $xml = << 308 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | EOT; 345 | $dom = new \DOMDocument(); 346 | $dom->loadXML($xml); 347 | $def->expects($this->once()) 348 | ->method('getDefinition') 349 | ->willReturn($dom); 350 | 351 | return $this->sut->createChart('Personal', $def); 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /test/php/SAccounts/ChartDefinitionTest.php: -------------------------------------------------------------------------------- 1 | 26 | 27 | EOT; 28 | 29 | protected function setUp(): void 30 | { 31 | $root = vfsStream::setup(); 32 | $file = vfsStream::newFile('test.xml') 33 | ->withContent($this->xmlFile) 34 | ->at($root); 35 | $this->filePath = $file->url(); 36 | } 37 | 38 | public function testCanConstructWithValidFileName() 39 | { 40 | $this->assertInstanceOf('SAccounts\ChartDefinition', new ChartDefinition($this->filePath)); 41 | } 42 | 43 | public function testConstructionWithInvalidFileNameWillThrowException() 44 | { 45 | $this->expectException(AccountsException::class); 46 | new ChartDefinition('foo'); 47 | } 48 | 49 | public function testConstructionWithValidFileNameWillReturnClass() 50 | { 51 | $sut = new ChartDefinition($this->filePath); 52 | $this->assertInstanceOf(ChartDefinition::class, $sut); 53 | } 54 | 55 | public function testGettingTheDefinitionWillThrowExceptionIfDefinitionFileIsInvalidXml() 56 | { 57 | $root = vfsStream::setup(); 58 | $file = vfsStream::newFile('test2.xml') 59 | ->withContent('') 60 | ->at($root); 61 | $sut = new ChartDefinition($file->url()); 62 | 63 | $this->expectException(AccountsException::class); 64 | $sut->getDefinition(); 65 | } 66 | 67 | public function testGettingDefinitionWillThrowExceptionIfDefinitionFailsValidation() 68 | { 69 | $this->expectException(AccountsException::class); 70 | $sut = new ChartDefinition($this->filePath); 71 | 72 | $this->assertInstanceOf('DOMDocument', $sut->getDefinition()); 73 | } 74 | 75 | public function testGettingTheDefinitionWillReturnADomDocumentWithValidDefinitionFile() 76 | { 77 | $xml = << 79 | 80 | 81 | 82 | 83 | 84 | EOT; 85 | 86 | $root = vfsStream::setup(); 87 | $file = vfsStream::newFile('test3.xml') 88 | ->withContent($xml) 89 | ->at($root); 90 | $sut = new ChartDefinition($file->url()); 91 | $this->assertInstanceOf('DOMDocument', $sut->getDefinition()); 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /test/php/SAccounts/ChartTest.php: -------------------------------------------------------------------------------- 1 | sut = new Chart('Foo Chart', $tree); 64 | } 65 | 66 | public function testConstructionCreatesChart() 67 | { 68 | $this->assertInstanceOf('SAccounts\Chart', $this->sut); 69 | } 70 | 71 | public function testYouCanGiveAChartAnOptionalTreeInConstruction() 72 | { 73 | $tree = new Node(); 74 | $sut = new Chart('Foo Chart', $tree); 75 | $this->assertInstanceOf(Chart::class, $sut); 76 | } 77 | 78 | 79 | public function testYouCanGetAnAccountIfItExists() 80 | { 81 | $ac = $this->sut->getAccount(new Nominal('2000')); 82 | $this->assertEquals('2000', $ac->getNominal()->get()); 83 | } 84 | 85 | public function testTryingToGetANonExistentAccountWillThrowAnException() 86 | { 87 | $this->expectException(AccountsException::class); 88 | $this->sut->getAccount(new Nominal('9999')); 89 | } 90 | 91 | public function testYouCanTestIfAChartHasAnAccount() 92 | { 93 | $this->assertTrue($this->sut->hasAccount(new Nominal('1000'))); 94 | $this->assertFalse($this->sut->hasAccount(new Nominal('9999'))); 95 | } 96 | 97 | public function testTryingToGetAParentIdOfANonExistentAccountWillThrowAnException() 98 | { 99 | $this->expectException(AccountsException::class); 100 | $this->sut->getParentId(new Nominal('9999')); 101 | } 102 | 103 | public function testGettingTheParentIdOfAnAccountThatHasAParentWillReturnTheParentNominal() 104 | { 105 | $this->assertEquals('0000', $this->sut->getParentId(new Nominal('2000'))->get()); 106 | } 107 | 108 | public function testYouCanProvideAnOptionalInternalIdWhenConstructingAChart() 109 | { 110 | $sut = new Chart( 111 | 'Foo', 112 | null, 113 | 12 114 | ); 115 | 116 | $this->assertEquals(12, $sut->id()); 117 | } 118 | 119 | public function testYouCanSetTheChartRootNode() 120 | { 121 | $ac1 = new Account( 122 | new Nominal('9998'), 123 | AccountType::ASSET(), 124 | 'Asset', 125 | 0, 126 | 0 127 | ); 128 | $root = new Node($ac1); 129 | $this->sut->setRootNode($root); 130 | $tree = $this->sut->getTree(); 131 | 132 | $this->assertEquals($root, $tree); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /test/php/SAccounts/Transaction/EntriesTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Entries::class, new Entries()); 23 | } 24 | 25 | public function testYouCanCreateAnEntriesCollectionsWithEntryValues() 26 | { 27 | $this->assertInstanceOf( 28 | 'SAccounts\Transaction\Entries', 29 | new Entries( 30 | array( 31 | $this->getEntry('7789', 1234, 'dr'), 32 | $this->getEntry('3456', 617, 'cr'), 33 | $this->getEntry('2001', 617, 'cr'), 34 | ) 35 | ) 36 | ); 37 | } 38 | 39 | public function testYouCannotCreateAnEntriesCollectionWithNonEntryValues() 40 | { 41 | $this->expectException(\RuntimeException::class); 42 | $this->expectExceptionMessage('Value 0 is not a SAccounts\Transaction\Entry'); 43 | new Entries(array(new \stdClass())); 44 | } 45 | 46 | public function testYouCanAddAnotherEntryToEntriesAndGetNewEntriesCollection() 47 | { 48 | $sut1 = new Entries( 49 | array( 50 | $this->getEntry('7789', 1234, 'dr'), 51 | $this->getEntry('3456', 617, 'cr'), 52 | $this->getEntry('2001', 617, 'cr'), 53 | ) 54 | ); 55 | 56 | $sut2 = $sut1->addEntry($this->getEntry('3333',1226,'cr')); 57 | 58 | $this->assertInstanceOf(Entries::class, $sut2); 59 | $this->assertEquals(3, count($sut1)); 60 | $this->assertEquals(4, count($sut2)); 61 | $this->assertTrue($sut1 != $sut2); 62 | } 63 | 64 | public function testCheckBalanceWillReturnTrueIfEntriesAreBalanced() 65 | { 66 | $sut1 = new Entries( 67 | array( 68 | $this->getEntry('7789', 1234, 'dr'), 69 | $this->getEntry('3456', 617, 'cr'), 70 | $this->getEntry('2001', 617, 'cr'), 71 | ) 72 | ); 73 | 74 | $this->assertTrue($sut1->checkBalance()); 75 | } 76 | 77 | public function testCheckBalanceWillReturnFalseIfEntriesAreNotBalanced() 78 | { 79 | $sut1 = new Entries( 80 | array( 81 | $this->getEntry('7789', 1234, 'dr'), 82 | $this->getEntry('3456', 617, 'cr'), 83 | ) 84 | ); 85 | 86 | $this->assertFalse($sut1->checkBalance()); 87 | } 88 | 89 | protected function getEntry($id, $amount, $type) 90 | { 91 | return new Entry( 92 | new Nominal($id), 93 | $amount, 94 | ($type == 'dr' ? AccountType::DR() : AccountType::CR()) 95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /test/php/SAccounts/Transaction/EntryTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Entry::class, $sut); 24 | } 25 | 26 | public function testAnEntryMustHaveCrOrDrType() 27 | { 28 | $sut = new Entry(new Nominal('9999'), 0, AccountType::CR()); 29 | $this->assertInstanceOf(Entry::class, $sut); 30 | $sut = new Entry(new Nominal('9999'), 0, AccountType::DR()); 31 | $this->assertInstanceOf(Entry::class, $sut); 32 | } 33 | 34 | /** 35 | * @dataProvider invalidAccountTypes 36 | * @param AccountType $type 37 | */ 38 | public function testConstructingAnEntryWithInvalidTypeWillThrowException($type) 39 | { 40 | $this->expectException(AccountsException::class); 41 | $sut = new Entry(new Nominal('9999'), 0, $type); 42 | } 43 | 44 | public function invalidAccountTypes() 45 | { 46 | return array( 47 | array(AccountType::ASSET()), 48 | array(AccountType::BANK()), 49 | array(AccountType::CUSTOMER()), 50 | array(AccountType::EQUITY()), 51 | array(AccountType::EXPENSE()), 52 | array(AccountType::INCOME()), 53 | array(AccountType::LIABILITY()), 54 | array(AccountType::REAL()), 55 | array(AccountType::SUPPLIER()), 56 | ); 57 | } 58 | 59 | public function testYouCanGetTheIdOfAnEntry() 60 | { 61 | $this->assertEquals( 62 | '9999', 63 | (new Entry(new Nominal('9999'), 0, AccountType::CR())) 64 | ->getId() 65 | ->get() 66 | ); 67 | } 68 | 69 | public function testYouCanGetTheAmountOfAnEntry() 70 | { 71 | $this->assertEquals( 72 | 100, 73 | (new Entry(new Nominal('9999'), 100, AccountType::CR())) 74 | ->getAmount() 75 | ); 76 | } 77 | 78 | public function testYouCanGetTheTypeOfAnEntry() 79 | { 80 | $this->assertEquals( 81 | AccountType::CR(), 82 | (new Entry(new Nominal('9999'), 1, AccountType::CR())) 83 | ->getType() 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /test/php/SAccounts/Transaction/SimpleTransactionTest.php: -------------------------------------------------------------------------------- 1 | sut = new SimpleTransaction(new Nominal('0000'), new Nominal('1000'), 1226); 28 | } 29 | 30 | public function testBasicConstructionSetsAnEmptyNoteOnTheTransaction() 31 | { 32 | $this->assertEquals('', $this->sut->getNote()); 33 | } 34 | 35 | public function testBasicConstructionSetsDateForTodayOnTheTransaction() 36 | { 37 | $dt = new \DateTime(); 38 | $date = $dt->format('yyyy-mm-dd'); 39 | $txnDate = $this->sut->getDate()->format('yyyy-mm-dd'); 40 | $this->assertEquals($date, $txnDate); 41 | } 42 | 43 | public function testYouCanSetAnOptionalNoteOnConstruction() 44 | { 45 | $note = 'foo bar'; 46 | $sut = new SimpleTransaction(new Nominal('0000'), new Nominal('1000'), 1226, $note); 47 | $this->assertEquals($note, $sut->getNote()); 48 | } 49 | 50 | public function testYouCanSetAnOptionalSourceOnConstruction() 51 | { 52 | $sut = new SimpleTransaction( 53 | new Nominal('0000'), 54 | new Nominal('1000'), 55 | 1226, 56 | null, 57 | 'PUR' 58 | ); 59 | $this->assertEquals('PUR', $sut->getSrc()); 60 | } 61 | 62 | public function testYouCanSetAnOptionalReferenceOnConstruction() 63 | { 64 | $sut = new SimpleTransaction( 65 | new Nominal('0000'), 66 | new Nominal('1000'), 67 | 1226, 68 | null, 69 | null, 70 | 22 71 | ); 72 | $this->assertEquals(22, $sut->getRef()); 73 | } 74 | 75 | public function testYouCanSetAnOptionalDateOnConstruction() 76 | { 77 | $note = 'foo bar'; 78 | $dt = new \DateTime(); 79 | $sut = new SimpleTransaction( 80 | new Nominal('0000'), 81 | new Nominal('1000'), 82 | 1226, 83 | $note, 84 | null, 85 | null, 86 | $dt); 87 | $this->assertEquals($dt, $sut->getDate()); 88 | } 89 | 90 | public function testConstructingATransactionDoesNotSetItsId() 91 | { 92 | $this->assertNull($this->sut->getId()); 93 | } 94 | 95 | public function testYouCanSetAndGetAnId() 96 | { 97 | $id = 1; 98 | $this->assertEquals($id, $this->sut->setId($id)->getId()); 99 | } 100 | 101 | public function testYouCanGetTheDebitAccountCode() 102 | { 103 | $this->assertEquals('0000', $this->sut->getDrAc()[0]->get()); 104 | } 105 | 106 | public function testYouCanGetTheCreditAccountCode() 107 | { 108 | $this->assertEquals('1000', $this->sut->getCrAc()[0]->get()); 109 | } 110 | 111 | public function testYouCanGetTheTransactionAmount() 112 | { 113 | $this->assertEquals(1226, $this->sut->getAmount()); 114 | } 115 | 116 | public function testYouCanGetTheTransactionNote() 117 | { 118 | $this->assertIsString($this->sut->getNote()); 119 | } 120 | 121 | public function testYouCanGetTheTransactionDatetime() 122 | { 123 | $this->assertInstanceOf(\DateTime::class, $this->sut->getDate()); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /test/php/SAccounts/Transaction/SplitTransactionTest.php: -------------------------------------------------------------------------------- 1 | sut = (new SplitTransaction()) 29 | ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR())) 30 | ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR())); 31 | } 32 | 33 | public function testBasicConstructionSetsAnEmptyNoteOnTheTransaction() 34 | { 35 | $this->assertEquals('', $this->sut->getNote()); 36 | } 37 | 38 | public function testBasicConstructionSetsDateForTodayOnTheTransaction() 39 | { 40 | $dt = new \DateTime(); 41 | $date = $dt->format('yyyy-mm-dd'); 42 | $txnDate = $this->sut->getDate()->format('yyyy-mm-dd'); 43 | $this->assertEquals($date, $txnDate); 44 | } 45 | 46 | public function testYouCanSetAnOptionalNoteOnConstruction() 47 | { 48 | $note = 'foo bar'; 49 | $amount = 1226; 50 | $sut = (new SplitTransaction($note)) 51 | ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR())) 52 | ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR())); 53 | $this->assertEquals($note, $sut->getNote()); 54 | } 55 | 56 | public function testANullNoteWillBeRetrievedAsAnEmptyString() 57 | { 58 | $amount = 1226; 59 | $sut = (new SplitTransaction()) 60 | ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR())) 61 | ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR())); 62 | $this->assertEquals('', $sut->getNote()); 63 | } 64 | 65 | public function testYouCanSetAnOptionalSourceOnConstruction() 66 | { 67 | $amount = 1226; 68 | $sut = (new SplitTransaction(null, 'PUR')) 69 | ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR())) 70 | ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR())); 71 | $this->assertEquals('PUR', $sut->getSrc()); 72 | } 73 | 74 | public function testANullSourceWillBeRetrievedAsAnEmptyString() 75 | { 76 | $amount = 1226; 77 | $sut = (new SplitTransaction()) 78 | ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR())) 79 | ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR())); 80 | $this->assertEquals('', $sut->getSrc()); 81 | } 82 | 83 | public function testYouCanSetAnOptionalReferenceOnConstruction() 84 | { 85 | $amount = 1226; 86 | $sut = (new SplitTransaction(null, null, 22)) 87 | ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR())) 88 | ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR())); 89 | $this->assertEquals(22, $sut->getRef()); 90 | } 91 | 92 | public function testANullReferenceWillBeRetrievedAsAZeroInteger() 93 | { 94 | $amount = 1226; 95 | $sut = (new SplitTransaction()) 96 | ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR())) 97 | ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR())); 98 | $this->assertEquals(0, $sut->getRef()); 99 | } 100 | 101 | public function testYouCanSetAnOptionalDateOnConstruction() 102 | { 103 | $note = 'foo bar'; 104 | $dt = new \DateTime(); 105 | $amount = 1226; 106 | $sut = (new SplitTransaction($note, null, null, $dt)) 107 | ->addEntry(new Entry(new Nominal('0000'), $amount, AccountType::DR())) 108 | ->addEntry(new Entry(new Nominal('1000'), $amount, AccountType::CR())); 109 | $this->assertEquals($dt, $sut->getDate()); 110 | } 111 | 112 | public function testConstructingASplitTransactionDoesNotSetItsId() 113 | { 114 | $this->assertNull($this->sut->getId()); 115 | } 116 | 117 | public function testYouCanSetAndGetAnId() 118 | { 119 | $id = 1; 120 | $this->assertEquals($id, $this->sut->setId($id)->getId()); 121 | } 122 | 123 | public function testGettingTheDebitAccountForASplitTransactionWillReturnAnArrayOfNominals() 124 | { 125 | $codes = $this->sut->getDrAc(); 126 | $this->assertIsArray($codes); 127 | $this->assertInstanceOf(Nominal::class, $codes[0]); 128 | } 129 | 130 | public function testGettingTheCreditAccountForASplitTransactionWillReturnAnArrayOfNominals() 131 | { 132 | $codes = $this->sut->getCrAc(); 133 | $this->assertIsArray($codes); 134 | $this->assertInstanceOf(Nominal::class, $codes[0]); 135 | } 136 | 137 | public function testCheckingIfASplitTransactionIsBalancedWillReturnTrueIfBalanced() 138 | { 139 | $this->assertTrue($this->sut->checkBalance()); 140 | } 141 | 142 | public function testCheckingIfASplitTransactionIsBalancedWillReturnFalseIfNotBalanced() 143 | { 144 | $amount = 10; 145 | $this->sut->addEntry(new Entry(new Nominal('2000'), $amount, AccountType::CR())); 146 | $this->assertFalse($this->sut->checkBalance()); 147 | } 148 | 149 | public function testYouCanGetTheTotalTransactionAmountIfTheTransactionIsBalanced() 150 | { 151 | $this->assertEquals(1226, $this->sut->getAmount()); 152 | } 153 | 154 | public function testIfTheTransactionIsNotBalancedGettingTheTotalTransactionAmountWillThrowAnException() 155 | { 156 | $this->expectException(AccountsException::class); 157 | $this->sut->addEntry(new Entry(new Nominal('2000'), 10, AccountType::CR())) 158 | ->getAmount(); 159 | } 160 | 161 | public function testYouCanGetTheTransactionNote() 162 | { 163 | $this->assertIsString($this->sut->getNote()); 164 | } 165 | 166 | public function testYouCanGetTheTransactionDatetime() 167 | { 168 | $this->assertInstanceOf(\DateTime::class, $this->sut->getDate()); 169 | } 170 | 171 | public function testASplitTransactionIsSimpleIfItHasOneDrAndOneCrEntry() 172 | { 173 | $this->assertTrue($this->sut->isSimple()); 174 | $this->sut->addEntry(new Entry(new Nominal('2000'), 10, AccountType::CR())); 175 | $this->assertFalse($this->sut->isSimple()); 176 | } 177 | 178 | public function testYouCanGetAnEntryByItsNominalId() 179 | { 180 | $this->sut->addEntry(new Entry(new Nominal('2000'), 10, AccountType::CR())); 181 | $test = $this->sut->getEntry(new Nominal('2000')); 182 | $this->assertInstanceOf(Entry::class, $test); 183 | } 184 | 185 | public function testGettingAnUnknownEntryWillThrowAnException() 186 | { 187 | $this->expectException(AccountsException::class); 188 | $this->expectExceptionMessage('Entry not found'); 189 | $this->sut->getEntry(new Nominal('2000')); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /test/php/SAccounts/Visitor/ChartArrayTest.php: -------------------------------------------------------------------------------- 1 | tree = new Node( 29 | new Account( 30 | new Nominal('0000'), 31 | AccountType::REAL(), 32 | 'COA', 33 | 1001, 34 | 1001 35 | ), 36 | [ 37 | new Node( 38 | new Account( 39 | new Nominal('1000'), 40 | AccountType::ASSET(), 41 | 'Assets', 42 | 1001, 43 | 0 44 | ) 45 | ), 46 | new Node( 47 | new Account( 48 | new Nominal('2000'), 49 | AccountType::LIABILITY(), 50 | 'Liabilities', 51 | 0, 52 | 1001 53 | ) 54 | ) 55 | ] 56 | ); 57 | } 58 | 59 | public function testConstructingWithNoCurrencyWillReturnIntegerValues() 60 | { 61 | $sut = new ChartArray(); 62 | $result = $this->tree->accept($sut); 63 | $expected = [ 64 | ['0000', 'COA', 1001, 1001, 0], 65 | ['1000', 'Assets', 1001, 0, 1001], 66 | ['2000', 'Liabilities', 0, 1001, 1001] 67 | ]; 68 | $this->assertEquals($expected, $result); 69 | } 70 | 71 | public function testConstructingWithACurrencyWillReturnFloatsDependentOnTheCurrencyPrecision() 72 | { 73 | $sut = new ChartArray(new Currency(0,'','',2)); 74 | $result = $this->tree->accept($sut); 75 | $expected = [ 76 | ['0000', 'COA', 10.01, 10.01, 0], 77 | ['1000', 'Assets', 10.01, 0, 10.01], 78 | ['2000', 'Liabilities', 0, 10.01, 10.01] 79 | ]; 80 | $this->assertEquals($expected, $result); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /test/php/SAccounts/Visitor/ChartPrinterTest.php: -------------------------------------------------------------------------------- 1 | tree = new Node( 34 | new Account( 35 | new Nominal('0000'), 36 | AccountType::REAL(), 37 | 'COA', 38 | 1001, 39 | 1001 40 | ), 41 | [ 42 | new Node( 43 | new Account( 44 | new Nominal('1000'), 45 | AccountType::ASSET(), 46 | 'Assets', 47 | 1001, 48 | 0 49 | ) 50 | ), 51 | new Node( 52 | new Account( 53 | new Nominal('2000'), 54 | AccountType::LIABILITY(), 55 | 'Liabilities', 56 | 0, 57 | 1001 58 | ) 59 | ) 60 | ] 61 | ); 62 | 63 | $this->sut = new ChartPrinter(new Currency(0, 'GBP', '£')); 64 | } 65 | 66 | public function testTheOutputIsSentToTheConsole() 67 | { 68 | $this->expectOutputRegex('/.*Nominal.*/'); 69 | $this->tree->accept($this->sut); 70 | } 71 | 72 | public function testOutputIsFormattedUsingTheCurrencySymbol() 73 | { 74 | $this->expectOutputRegex('/.*£.*/'); 75 | $this->tree->accept($this->sut); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/php/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 26 | 27 | 31 | 32 | 33 | ../../src/php/SAccounts 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /test/sql/add_account_test.sql: -------------------------------------------------------------------------------- 1 | # Test script for simple accounts database 2 | # Copyright, 2018, Ashley Kitson, UK 3 | # License: GPL V3+, see License.md 4 | 5 | #before test 6 | DELETE FROM sa_coa; 7 | SET @testName = 'add account'; 8 | 9 | #test 10 | SELECT sa_fu_add_chart('Test') INTO @chartId; 11 | 12 | CALL sa_sp_add_ledger(@chartId,'0000', 'REAL', 'COA', ''); 13 | CALL sa_sp_add_ledger(@chartId,'1000', 'ASSET', 'Assets', '0000'); 14 | CALL sa_sp_add_ledger(@chartId,'2000', 'LIABILITY', 'Liabilities', '0000'); 15 | 16 | #results 17 | SELECT count(*) FROM sa_coa INTO @numCharts; 18 | SELECT count(*) FROM sa_coa_ledger INTO @numLedgers; 19 | SELECT id from sa_coa_ledger where nominal = '0000' into @baseId; 20 | SELECT prntId from sa_coa_ledger where nominal = '1000' into @nom1000prnt; 21 | SELECT prntId from sa_coa_ledger where nominal = '2000' into @nom2000prnt; 22 | 23 | 24 | #output 25 | SELECT 26 | @testName as test, 27 | 'number of charts = 1' as spec, 28 | if(@numCharts = 1, 'Passed', 'Failed') as result 29 | UNION SELECT 30 | @testName as test, 31 | 'number of ledgers = 3' as spec, 32 | if(@numLedgers = 3, 'Passed', 'Failed') as result 33 | UNION SELECT 34 | @testName as test, 35 | 'parent id for ac 1000 is correct' as spec, 36 | if(@baseId = @nom1000prnt, 'Passed', 'Failed') as result 37 | UNION SELECT 38 | @testName as test, 39 | 'parent id for ac 2000 is correct' as spec, 40 | if(@baseId = @nom2000prnt, 'Passed', 'Failed') as result 41 | ; 42 | -------------------------------------------------------------------------------- /test/sql/add_transaction_test.sql: -------------------------------------------------------------------------------- 1 | # Test script for simple accounts database 2 | # Copyright, 2018, Ashley Kitson, UK 3 | # License: GPL V3+, see License.md 4 | 5 | #before test 6 | DELETE FROM sa_coa; 7 | SET @testName = 'add transaction'; 8 | 9 | #test 10 | SELECT sa_fu_add_chart('Test') 11 | INTO @chartId; 12 | 13 | CALL sa_sp_add_ledger(@chartId, '0000', 'REAL', 'COA', ''); 14 | CALL sa_sp_add_ledger(@chartId, '1000', 'ASSET', 'Assets', '0000'); 15 | CALL sa_sp_add_ledger(@chartId, '2000', 'LIABILITY', 'Liabilities', '0000'); 16 | #test 1 17 | SELECT sa_fu_add_txn(@chartId, 'foo', NULL, 'PUR', 23 , '1000,2000', '12,12', 'dr,cr') 18 | INTO @jrnId; 19 | #test 2 20 | SELECT (note = 'foo') 21 | FROM sa_journal 22 | INTO @testnote; 23 | #test 3 24 | SELECT !isnull(date) 25 | FROM sa_journal 26 | INTO @testdate; 27 | #test 5 28 | SELECT count(*) 29 | FROM sa_journal_entry 30 | WHERE jrnId = @jrnId 31 | INTO @entryCount; 32 | #test 6 33 | SELECT 34 | acDr, 35 | acCr 36 | FROM sa_coa_ledger 37 | WHERE nominal = '1000' 38 | AND chartId = @chartId 39 | INTO @ac1000dr, @ac1000cr; 40 | SELECT 41 | acDr, 42 | acCr 43 | FROM sa_coa_ledger 44 | WHERE nominal = '2000' 45 | AND chartId = @chartId 46 | INTO @ac2000dr, @ac2000cr; 47 | #test 7 48 | SELECT 49 | acDr, 50 | acCr 51 | FROM sa_coa_ledger 52 | WHERE nominal = '0000' 53 | AND chartId = @chartId 54 | INTO @ac0000dr, @ac0000cr; 55 | #test 8 56 | SELECT (src = 'PUR') 57 | FROM sa_journal 58 | WHERE id = @jrnId 59 | INTO @testsrc; 60 | #test 9 61 | SELECT (ref = 23) 62 | FROM sa_journal 63 | WHERE id = @jrnId 64 | INTO @testref; 65 | 66 | #output 67 | SELECT 68 | @testName AS test, 69 | 'adding journal creates journal header' AS spec, 70 | if(@jrnId > 0, 'Passed', 'Failed') AS result 71 | UNION SELECT 72 | @testName AS test, 73 | 'note is added if supplied' AS spec, 74 | if(@testnote, 'Passed', 'Failed') AS result 75 | UNION SELECT 76 | @testName AS test, 77 | 'src is added if supplied' AS spec, 78 | # @testsrc as result 79 | if(@testsrc, 'Passed', 'Failed') AS result 80 | UNION SELECT 81 | @testName AS test, 82 | 'ref is added if supplied' AS spec, 83 | if(@testref, 'Passed', 'Failed') AS result 84 | UNION SELECT 85 | @testName AS test, 86 | 'date is defaulted if not supplied' AS spec, 87 | if(@testdate, 'Passed', 'Failed') AS result 88 | UNION SELECT 89 | @testName AS test, 90 | 'transaction entries are written' AS spec, 91 | if(@entryCount = 2, 'Passed', 'Failed') AS result 92 | UNION SELECT 93 | @testName AS test, 94 | 'transaction entries update the ledgers' AS spec, 95 | if(@ac1000dr = 12 AND @ac1000cr = 0 AND @ac2000dr = 0 AND @ac2000cr = 12, 96 | 'Passed', 'Failed') AS result 97 | UNION SELECT 98 | @testName AS test, 99 | 'transaction entries update ledger parent accounts' AS spec, 100 | if(@ac0000dr = @ac0000cr AND @ac0000cr > 0, 101 | 'Passed', 'Failed') AS result; 102 | -------------------------------------------------------------------------------- /test/sql/delete_account_test.sql: -------------------------------------------------------------------------------- 1 | # Test script for simple accounts database 2 | # Copyright, 2018, Ashley Kitson, UK 3 | # License: GPL V3+, see License.md 4 | 5 | #before test 6 | DELETE FROM sa_coa; 7 | SET @testName = 'delete account'; 8 | 9 | #test 10 | SELECT sa_fu_add_chart('Test') INTO @chartId; 11 | 12 | CALL sa_sp_add_ledger(@chartId,'0000', 'REAL', 'COA', ''); 13 | CALL sa_sp_add_ledger(@chartId,'1000', 'ASSET', 'Assets', '0000'); 14 | CALL sa_sp_add_ledger(@chartId,'2000', 'LIABILITY', 'Liabilities', '0000'); 15 | #test 1 16 | CALL sa_sp_del_ledger(@chartId,'2000'); 17 | SELECT count(*) FROM sa_coa_ledger INTO @numLedgers1; 18 | #test 2 19 | CALL sa_sp_del_ledger(@chartId,'0000'); 20 | SELECT count(*) FROM sa_coa_ledger INTO @numLedgers2; 21 | 22 | #output 23 | SELECT 24 | @testName as test, 25 | 'deleting leaf account' as spec, 26 | if(@numLedgers1 = 2, 'Passed', 'Failed') as result 27 | UNION SELECT 28 | @testName as test, 29 | 'deleting parent account' as spec, 30 | if(@numLedgers2 = 0, 'Passed', 'Failed') as result 31 | ; 32 | --------------------------------------------------------------------------------