chippyash/Simple-Accounts

View on GitHub
src/Chippyash/SAccounts/Storage/Journal/Xml.php

Summary

Maintainability
A
1 hr
Test Coverage
<?php
/**
 * Simple Double Entry Accounting
 *
 * @author Ashley Kitson
 * @copyright Ashley Kitson, 2015, UK
 * @license GPL V3+ See LICENSE.md
 */
namespace SAccounts\Storage\Journal;

use SAccounts\AccountsException;
use SAccounts\AccountType;
use SAccounts\Journal;
use SAccounts\JournalStorageInterface;
use SAccounts\Nominal;
use SAccounts\Transaction\Entry;
use SAccounts\Transaction\SplitTransaction;
use Chippyash\Currency\Factory as CurrencyFactory;
use Chippyash\Type\Number\IntType;
use Chippyash\Type\String\StringType;

/**
 * Xml File Storage for a Journal
 */
class Xml implements JournalStorageInterface
{
    /**
     * Path to journal storage file
     * @var StringType
     */
    protected $filePath;

    /**
     * Normalized path to journal file
     * @var StringType
     */
    protected $journalPath;

    /**
     * Template for new journal definition xml file
     * @var string
     */
    protected $template = <<<EOT
<?xml version="1.0"?>
<journal>
    <definition name="" crcy="GBP" inc="0"/>
    <transactions/>
</journal>
EOT;

    /**
     * @var StringType
     */
    protected $journalName;

    /**
     * Constructor
     *
     * @param StringType $filePath
     * @param StringType $journalName
     */
    public function __construct(StringType $filePath, StringType $journalName = null)
    {
        $this->filePath = $filePath;
        if (!is_null($journalName)) {
            $this->setJournalName($journalName);
        }
    }

    /**
     * Set the journal that we will next be working with
     *
     * @param StringType $name
     *
     * @return $this
     */
    public function setJournalName(StringType $name)
    {
        $this->journalName = $name;
        $this->normalizeFilePath($name);

        return $this;
    }

    /**
     * Write Journal definition to store
     * side effect: will set current journal
     *
     * @param Journal $journal
     *
     * @return bool
     */
    public function writeJournal(Journal $journal)
    {
        $this->setJournalName($journal->getName());
        if (file_exists($this->journalPath->get())) {
            return $this->amendJournal($journal);
        } else {
            return $this->createJournal($journal);
        }
    }

    /**
     * Read journal definition from store
     *
     * @return Journal
     * @throws \SAccounts\AccountsException
     */
    public function readJournal()
    {
        if (!isset($this->journalName)) {
            throw new AccountsException('Missing Journal name');
        }
        if (!file_exists($this->journalPath->get())) {
            throw new AccountsException('Missing Journal file');
        }
        //check to make sure Journal is valid
        $attribs = $this->getDefinition();

        $crcy = CurrencyFactory::create($attribs->getNamedItem('crcy')->nodeValue);
        $journal = new Journal($this->journalName, $crcy, $this);

        return $journal;
    }

    /**
     * Write a transaction to store
     *
     * @param SplitTransaction $transaction
     *
     * @return IntType Transaction Unique Id
     * @throws \SAccounts\AccountsException
     */
    public function writeTransaction(SplitTransaction $transaction)
    {
        if (!isset($this->journalName)) {
            throw new AccountsException('Missing Journal name');
        }

        $dom = $this->getDom();
        $xpath = new \DOMXPath($dom);
        $def = $xpath->query('/journal/definition')->item(0);
        $attribs = $def->attributes;
        $txnId = (intval($attribs->getNamedItem('inc')->nodeValue)) + 1;
        $attribs->getNamedItem('inc')->nodeValue = $txnId;

        $transactions = $xpath->query('/journal/transactions')->item(0);

        $newTxn = $dom->createElement('transaction');
        $newTxn->setAttribute('id', $txnId);
        //NB - although we are looking for an ISO801 format to match xs:datetime
        //the W3C format actually matches xsd datetime. PHP ISO8601 does not
        $newTxn->setAttribute('date', $transaction->getDate()->format(\DateTime::W3C));
        $newTxn->setAttribute('note', $transaction->getNote()->get());

        $this->writeSplitFromTransaction($transaction, $dom, $newTxn);
        $transactions->appendChild($newTxn);

        $dom->save($this->journalPath->get());

        return new IntType($txnId);
    }

    /**
     * Read a transaction from store
     *
     * @param IntType $id Transaction Unique Id
     *
     * @return SplitTransaction|null
     * @throws \SAccounts\AccountsException
     */
    public function readTransaction(IntType $id)
    {
        if (!isset($this->journalName)) {
            throw new AccountsException('Missing Journal name');
        }

        $dom = $this->getDom();
        $xpath = new \DOMXPath($dom);
        
        $nodes = $xpath->query("/journal/transactions/transaction[@id='{$id}']");
        if ($nodes->length !== 1) {
            return null;
        }
        
        $txn = $nodes->item(0);

        $crcy = $xpath->query('/journal/definition')->item(0)->attributes->getNamedItem('crcy')->nodeValue;

        return $this->createTransactionFromElement($txn, $crcy, $xpath);
    }

    /**
     * Return all transactions for an account from store
     *
     * @param Nominal $nominal Account Nominal code
     *
     * @return array[SplitTransaction,...]
     */
    public function readTransactions(Nominal $nominal)
    {
        $dom = $this->getDom();
        $xpath = new \DOMXPath($dom);
        $nodes = $xpath->query("/journal/transactions/transaction/split[@nominal='{$nominal}']/..");
        if ($nodes->length === 0){
            return array();
        }

        $crcy = $xpath->query('/journal/definition')->item(0)->attributes->getNamedItem('crcy')->nodeValue;
        $transactions = array();

        foreach ($nodes as $node) {
            $transactions[] = $this->createTransactionFromElement($node, $crcy, $xpath);
        }

        return $transactions;
    }

    /**
     * Create transaction from dom element
     *
     * @param \DOMElement $txn
     * @param $crcyCode
     * @param \DOMXPath
     *
     * @return SplitTransaction
     */
    protected function createTransactionFromElement(\DOMElement $txn, $crcyCode, \DOMXPath $xpath)
    {
        $drNodes = $xpath->query("./split[@type='DR']", $txn);
        $transaction = (new SplitTransaction(
            new \DateTime($txn->attributes->getNamedItem('date')->nodeValue),
            new StringType($txn->attributes->getNamedItem('note')->nodeValue)
        ))
            ->setId(
                new IntType($txn->attributes->getNamedItem('id')->nodeValue)
        );
        foreach ($drNodes as $drNode) {
            $transaction
                ->addEntry(
                    new Entry(
                        new Nominal($drNode->attributes->getNamedItem('nominal')->nodeValue),
                        CurrencyFactory::create($crcyCode)
                            ->set(intval($drNode->attributes->getNamedItem('amount')->nodeValue)),
                        AccountType::DR()
                    )
                );
        }

        $crNodes = $xpath->query("./split[@type='CR']", $txn);
        foreach ($crNodes as $crNode) {
            $transaction
                ->addEntry(
                    new Entry(
                        new Nominal($crNode->attributes->getNamedItem('nominal')->nodeValue),
                        CurrencyFactory::create($crcyCode)
                            ->set(intval($crNode->attributes->getNamedItem('amount')->nodeValue)),
                        AccountType::CR()
                    )
                );
        }

        return $transaction;
    }

    /**
     * Set the normalized journal file name
     * @param StringType $journalName
     */
    protected function normalizeFilePath(StringType $journalName)
    {
        $this->journalPath = new StringType($this->filePath . '/' . strtolower(str_replace(' ', '-', $journalName)) . '.xml');
    }

    /**
     * Amend existing journal definition
     *
     * @param Journal $journal
     * @return bool
     */
    protected function amendJournal(Journal $journal)
    {
        $dom = new \DOMDocument();
        $dom->load($this->journalPath->get());

        return $this->updateJournalContent($dom, $journal);
    }

    /**
     * Create a new journal definition
     *
     * @param Journal $journal
     * @return bool
     */
    protected function createJournal(Journal $journal)
    {
        $dom = new \DOMDocument();
        $dom->loadXML($this->template);

        return $this->updateJournalContent($dom, $journal);
    }

    /**
     * Update content of journal definition
     *
     * @param \DOMDocument $dom
     * @param Journal $journal
     * @return bool
     */
    protected function updateJournalContent(\DOMDocument $dom, Journal $journal)
    {
        $xpath = new \DOMXPath($dom);
        $defNode = $xpath->query('/journal/definition')->item(0);
        $attributes = $defNode->attributes;
        $attributes->getNamedItem('name')->nodeValue = $journal->getName()->get();
        $attributes->getNamedItem('crcy')->nodeValue = $journal->getCurrency()->getCode();

        return ($dom->save($this->journalPath->get()) !== false);
    }

    /**
     * Read and validate a journal definition
     *
     * @return \DOMNamedNodeMap
     */
    protected function getDefinition()
    {
        $xpath = new \DOMXPath($this->getDom());

        return $xpath->query('/journal/definition')->item(0)->attributes;
    }

    /**
     * Get journal definition as Dom
     *
     * @return \DOMDocument
     * @throws AccountsException
     */
    protected function getDom()
    {
        $dom = new \DOMDocument();
        $dom->load($this->journalPath->get());
        $schemaPath = realpath(__DIR__ . '/../../definitions/journal-definition.xsd');

        libxml_use_internal_errors(true);
        if (!$dom->schemaValidate($schemaPath)) {
            $err = libxml_get_last_error()->message;
            libxml_clear_errors();
            libxml_use_internal_errors(false);
            throw new AccountsException('Definition does not validate: ' . $err);
        }

        libxml_use_internal_errors(false);

        return $dom;
    }

    /**
     * Break transaction into its splits and write them out
     *
     * @param SplitTransaction $transaction
     * @param \DOMDocument $dom
     * @param \DOMElement $newTxn
     */
    protected function writeSplitFromTransaction(SplitTransaction $transaction, \DOMDocument $dom, \DOMElement $newTxn)
    {
        foreach ($transaction->getEntries() as $entry) {
            $this->writeSplit(
                $dom,
                $newTxn,
                ($entry->getType()->getValue() == AccountType::CR ? 'CR' : 'DR'),
                $entry->getAmount()->get(),
                $entry->getId()->get()
            );
        }
    }

    /**
     * Write a transaction split to dom
     *
     * @param \DOMDocument $dom
     * @param \DOMElement $txn
     * @param string $type
     * @param int $amount
     * @param string $nominal
     */
    protected function writeSplit(\DOMDocument $dom, \DOMElement $txn, $type, $amount, $nominal)
    {
        $split = $dom->createElement('split');
        $split->setAttribute('type', $type);
        $split->setAttribute('amount', $amount);
        $split->setAttribute('nominal', $nominal);
        $txn->appendChild($split);
    }
}