diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..de09990 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,36 @@ +name: Unit Tests + +on: [push] + # push: + # branches: [ $default-branch ] + # pull_request: + # branches: [ $default-branch ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" + # Docs: https://getcomposer.org/doc/articles/scripts.md + + - name: Run test suite + run: composer run-script test diff --git a/.gitignore b/.gitignore index 2e3beb6..c455bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea .DS_Store vendor -composer.lock \ No newline at end of file +composer.lock +.phpunit.*cache diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index 200fd30..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,10 +0,0 @@ -inherit: true - -before_commands: - - "composer install --no-dev --prefer-source" - -tools: - external_code_coverage: - enabled: true - timeout: 600 - runs: 1 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index feb83ef..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: php - -php: - - 5.6 - - 7.0 - -before_script: - - composer update --prefer-dist $DEPENDENCIES - -script: - - vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml --colors - -after_script: - - if [ $TRAVIS_PHP_VERSION = '7.0' ]; then wget https://scrutinizer-ci.com/ocular.phar; php ocular.phar code-coverage:upload --format=php-clover clover.xml; fi diff --git a/README.md b/README.md index 880f43a..735f7c2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ OFX Parser ================= -[![Build Status](https://travis-ci.org/asgrim/ofxparser.svg?branch=master)](https://travis-ci.org/asgrim/ofxparser) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/asgrim/ofxparser/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/asgrim/ofxparser/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/asgrim/ofxparser/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/asgrim/ofxparser/?branch=master) [![Latest Stable Version](https://poser.pugx.org/asgrim/ofxparser/v/stable)](https://packagist.org/packages/asgrim/ofxparser) [![License](https://poser.pugx.org/asgrim/ofxparser/license)](https://packagist.org/packages/asgrim/ofxparser) +[![Unit Tests](https://github.com/gitmathias/ofxparser/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/gitmathias/ofxparser/actions/workflows/unit-tests.yml) OFX Parser is a PHP library designed to parse an OFX file downloaded from a financial institution into simple PHP objects. @@ -12,7 +12,7 @@ It supports multiple Bank Accounts, the required "Sign On" response, and recogni Simply require the package using [Composer](https://getcomposer.org/): ```bash -$ composer require asgrim/ofxparser +$ composer require gitmathias/ofxparser ``` ## Usage @@ -79,5 +79,6 @@ foreach ($ofx->bankAccounts as $accountData) { ## Fork & Credits -This is a fork of [grimfor/ofxparser](https://github.com/Grimfor/ofxparser) made to be framework independent. The source repo was designed for Symfony 2 framework, so credit should be given where credit due! -Heavily refactored by [Oliver Lowe](https://github.com/loweoj) and loosely based on the ruby [ofx-parser by Andrew A. Smith](https://github.com/aasmith/ofx-parser). +Fork of archived project [asgrim/ofxparser](https://github.com/asgrim/ofxparser). + +Archive was originally forked from [grimfor/ofxparser](https://github.com/Grimfor/ofxparser) and made to be framework independent. Heavily refactored by [Oliver Lowe](https://github.com/loweoj) and loosely based on the ruby [ofx-parser by Andrew A. Smith](https://github.com/aasmith/ofx-parser). diff --git a/composer.json b/composer.json index 0104198..49c3ea6 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "asgrim/ofxparser", - "description": "Simple OFX file parser", + "name": "gitmathias/ofxparser", + "description": "OFX file parser with Investments support", "keywords": ["ofx", "open financial exchange", "finance", "parser"], "license": "MIT", "authors": [ @@ -17,18 +17,24 @@ { "name": "Oliver Lowe", "email": "mrtriangle@gmail.com" + }, + { + "name": "Mathias Gran", + "email": "gitmathias@users.noreply.github.com" } ], "require": { - "php": "~5.6|~7.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "~5.5", - "squizlabs/php_codesniffer": "~2.6" + "phpunit/phpunit": "^9.5" }, - "autoload": { - "psr-0": { - "OfxParser": "lib/" + "autoload": { + "psr-4": { + "OfxParser\\": "lib/OfxParser/" } + }, + "scripts": { + "test": "vendor/bin/phpunit" } } diff --git a/lib/OfxParser/Entities/Investment.php b/lib/OfxParser/Entities/Investment.php index e6e3329..7c70725 100644 --- a/lib/OfxParser/Entities/Investment.php +++ b/lib/OfxParser/Entities/Investment.php @@ -4,8 +4,16 @@ use SimpleXMLElement; +use OfxParser\Entities\LoaderTrait; + abstract class Investment extends AbstractEntity implements Inspectable, OfxLoadable { + /** + * Make loadMap() available to all Invesment entities. + * @trait + */ + use LoaderTrait; + /** * Get a list of properties defined for this entity. * diff --git a/lib/OfxParser/Entities/Investment/Transaction/BuySecurity.php b/lib/OfxParser/Entities/Investment/Transaction/BuySecurity.php index 33ebdb0..33cc4a6 100644 --- a/lib/OfxParser/Entities/Investment/Transaction/BuySecurity.php +++ b/lib/OfxParser/Entities/Investment/Transaction/BuySecurity.php @@ -3,42 +3,21 @@ namespace OfxParser\Entities\Investment\Transaction; use SimpleXMLElement; -use OfxParser\Entities\AbstractEntity; + use OfxParser\Entities\Investment; -use OfxParser\Entities\Investment\Transaction\Traits\InvTran; -use OfxParser\Entities\Investment\Transaction\Traits\SecId; -use OfxParser\Entities\Investment\Transaction\Traits\Pricing; +use OfxParser\Entities\Investment\Transaction\Traits\InvBuy; /** * OFX 203 doc: - * 13.9.2.4.3 Investment Buy/Sell Aggregates / - * - * Properties found in the aggregate. - * Used for "other securities" BUY activities and provides the - * base properties to extend for more specific activities. - * - * Required: - * aggregate - * aggregate - * - * - * - * - * - * - * Optional: - * ...many... - * - * Partial implementation. + * 13.9.2.4.4 Investment Transaction Aggregates + * is simply an */ class BuySecurity extends Investment { /** * Traits used to define properties */ - use InvTran; - use SecId; - use Pricing; + use InvBuy; /** * @var string @@ -53,10 +32,7 @@ class BuySecurity extends Investment public function loadOfx(SimpleXMLElement $node) { // Transaction data is nested within child node - $this->loadInvTran($node->INVBUY->INVTRAN) - ->loadSecId($node->INVBUY->SECID) - ->loadPricing($node->INVBUY); - + $this->loadInvBuy($node->INVBUY); return $this; } } diff --git a/lib/OfxParser/Entities/Investment/Transaction/SellSecurity.php b/lib/OfxParser/Entities/Investment/Transaction/SellSecurity.php index 82e3f9b..72eb08b 100644 --- a/lib/OfxParser/Entities/Investment/Transaction/SellSecurity.php +++ b/lib/OfxParser/Entities/Investment/Transaction/SellSecurity.php @@ -3,42 +3,21 @@ namespace OfxParser\Entities\Investment\Transaction; use SimpleXMLElement; -use OfxParser\Entities\AbstractEntity; + use OfxParser\Entities\Investment; -use OfxParser\Entities\Investment\Transaction\Traits\InvTran; -use OfxParser\Entities\Investment\Transaction\Traits\SecId; -use OfxParser\Entities\Investment\Transaction\Traits\Pricing; +use OfxParser\Entities\Investment\Transaction\Traits\InvSell; /** * OFX 203 doc: - * 13.9.2.4.3 Investment Buy/Sell Aggregates / - * - * Properties found in the aggregate. - * Used for "other securities" SELL activities and provides the - * base properties to extend for more specific activities. - * - * Required: - * aggregate - * aggregate - * - * - * - * - * - * - * Optional: - * ...many... - * - * Partial implementation. + * 13.9.2.4.4 Investment Transaction Aggregates + * is simply an */ class SellSecurity extends Investment { /** * Traits used to define properties */ - use InvTran; - use SecId; - use Pricing; + use InvSell; /** * @var string @@ -52,11 +31,7 @@ class SellSecurity extends Investment */ public function loadOfx(SimpleXMLElement $node) { - // Transaction data is nested within child node - $this->loadInvTran($node->INVSELL->INVTRAN) - ->loadSecId($node->INVSELL->SECID) - ->loadPricing($node->INVSELL); - + $this->loadInvSell($node->INVSELL); return $this; } } diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/InvBuy.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/InvBuy.php new file mode 100644 index 0000000..e08b6db --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/InvBuy.php @@ -0,0 +1,84 @@ +/ + * + * Properties found in the aggregate. + * Used for "other securities" BUY activities and provides the + * base properties to extend for more specific activities. + * + * Required: + * aggregate + * aggregate + * + * + * + * + * + * + * Optional: + * + * ...many... + * + * Partial implementation. + */ +trait InvBuy +{ + /** + * Traits used to define properties + */ + use LoaderTrait; + use InvTran; + use SecId; + use Pricing; + + /** + * @var float + */ + public $commission; + + /** + * @var float + */ + public $taxes; + + /** + * @var float + */ + public $fees; + + /** + * @var float + */ + public $load; + + /** + * @param SimpleXMLElement $node + * @return $this for chaining + */ + protected function loadInvBuy(SimpleXMLElement $node) + { + $this->loadInvTran($node->INVTRAN) + ->loadSecId($node->SECID) + ->loadPricing($node) + // These are all optional fields: + ->loadMap([ + 'commission' => 'COMMISSION', + 'taxes' => 'TAXES', + 'fees' => 'FEES', + 'load' => 'LOAD', + ], $node); + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/InvSell.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/InvSell.php new file mode 100644 index 0000000..dfe0562 --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/InvSell.php @@ -0,0 +1,90 @@ +/ + * + * Properties found in the aggregate. + * Used for "other securities" SELL activities and provides the + * base properties to extend for more specific activities. + * + * Required: + * aggregate + * aggregate + * + * + * + * + * + * + * Optional: + * + * ...many... + * + * Partial implementation. + */ +trait InvSell +{ + /** + * Traits used to define properties + */ + use LoaderTrait; + use InvTran; + use SecId; + use Pricing; + + /** + * @var float + */ + public $commission; + + /** + * @var float + */ + public $taxes; + + /** + * @var float + */ + public $fees; + + /** + * @var float + */ + public $load; + + /** + * @var float + */ + public $gain; + + /** + * @param SimpleXMLElement $node + * @return $this for chaining + */ + protected function loadInvSell(SimpleXMLElement $node) + { + $this->loadInvTran($node->INVTRAN) + ->loadSecId($node->SECID) + ->loadPricing($node) + // These are all optional fields: + ->loadMap([ + 'commission' => 'COMMISSION', + 'taxes' => 'TAXES', + 'fees' => 'FEES', + 'load' => 'LOAD', + 'gain' => 'GAIN', + ], $node); + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/InvTran.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/InvTran.php index c573170..10d0a90 100644 --- a/lib/OfxParser/Entities/Investment/Transaction/Traits/InvTran.php +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/InvTran.php @@ -3,6 +3,8 @@ namespace OfxParser\Entities\Investment\Transaction\Traits; use SimpleXMLElement; + +use OfxParser\Entities\LoaderTrait; use OfxParser\Utils; /** @@ -13,6 +15,8 @@ */ trait InvTran { + use LoaderTrait; + /** * This is the unique identifier in the broker's system, * NOT to be confused with the UNIQUEID node for the security. @@ -26,12 +30,25 @@ trait InvTran */ public $tradeDate; + /** + * The unique ID (FITID) of a prior transaction that + * is being reversed. + * @var string + */ + public $reversalUniqueId; + /** * Date the trade was settled * @var \DateTimeInterface */ public $settlementDate; + /** + * Server assigned transaction ID + * @var string + */ + public $serverTransactionId; + /** * Transaction memo, as provided from broker. * @var string @@ -49,12 +66,17 @@ protected function loadInvTran(SimpleXMLElement $node) // - all others optional $this->uniqueId = (string) $node->FITID; $this->tradeDate = Utils::createDateTimeFromStr($node->DTTRADE); + + // Optional, but loadMap doesn't support callbacks, atm. if (isset($node->DTSETTLE)) { $this->settlementDate = Utils::createDateTimeFromStr($node->DTSETTLE); } - if (isset($node->MEMO)) { - $this->memo = (string) $node->MEMO; - } + + $this->loadMap([ + 'serverTransactionId' => 'SRVRTID', + 'memo' => 'MEMO', + 'reversalUniqueId' => 'REVERSALFITID', + ], $node); return $this; } diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/Pricing.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/Pricing.php index d57cc96..9797ded 100644 --- a/lib/OfxParser/Entities/Investment/Transaction/Traits/Pricing.php +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/Pricing.php @@ -4,11 +4,18 @@ use SimpleXMLElement; +use OfxParser\Entities\LoaderTrait; + /** * Combo for units, price, and total */ trait Pricing { + /** + * Traits used to define properties + */ + use LoaderTrait; + /** * @var float */ @@ -44,11 +51,13 @@ trait Pricing */ protected function loadPricing(SimpleXMLElement $node) { - $this->units = (string) $node->UNITS; - $this->unitPrice = (string) $node->UNITPRICE; - $this->total = (string) $node->TOTAL; - $this->subAccountFund = (string) $node->SUBACCTFUND; - $this->subAccountSec = (string) $node->SUBACCTSEC; + $this->loadMap([ + 'units' => 'UNITS', + 'unitPrice' => 'UNITPRICE', + 'total' => 'TOTAL', + 'subAccountFund' => 'SUBACCTFUND', + 'subAccountSec' => 'SUBACCTSEC', + ], $node); return $this; } diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/SecId.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/SecId.php index 9bb3308..99780d8 100644 --- a/lib/OfxParser/Entities/Investment/Transaction/Traits/SecId.php +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/SecId.php @@ -4,12 +4,19 @@ use SimpleXMLElement; +use OfxParser\Entities\LoaderTrait; + /** * OFX 203 doc: * 13.8.1 Security Identification */ trait SecId { + /** + * Traits used to define properties + */ + use LoaderTrait; + /** * Identifier for the security being traded. * @var string @@ -30,8 +37,10 @@ protected function loadSecId(SimpleXMLElement $node) { // // - REQUIRED: , - $this->securityId = (string) $node->UNIQUEID; - $this->securityIdType = (string) $node->UNIQUEIDTYPE; + $this->loadMap([ + 'securityId' => 'UNIQUEID', + 'securityIdType' => 'UNIQUEIDTYPE', + ], $node); return $this; } diff --git a/lib/OfxParser/Entities/LoaderTrait.php b/lib/OfxParser/Entities/LoaderTrait.php new file mode 100644 index 0000000..289545a --- /dev/null +++ b/lib/OfxParser/Entities/LoaderTrait.php @@ -0,0 +1,60 @@ + node_detail, ...) + * + * If node_detail is a string, the value of that xml node will + * be assigned to the instance property in property_name. + * + * If node_detail is an array, the first value will be used + * as the xml node name and the second value will be used as + * the default value, in case the node does not exist. + * + * Example: $map = [ 'gain' => 'GAIN' ] + * Pull the value of the GAIN xml node and assign to $this->gain, + * if the GAIN xml node exists. + * + * Example: $map = [ 'fees' => ['FEES', 0]] + * Pull the value of the FEES xml node and assign to $this->fees, + * if the FEES xml node exists. If the node does not exist, assign + * zero to $this->fees. + * + * @param SimpleXMLElement $node + * @return $this + */ + public function loadMap($map, $node) + { + foreach ($map as $propName => $detail) { + $default = null; + $defaultProvided = false; + + if (is_array($detail)) { + $defaultProvided = true; + $detail = array_values($detail); + list($nodeName, $default) = $detail; + } else { + $nodeName = $detail; + } + + if (@count($node->{$nodeName}) > 0) { + $this->{$propName} = (string) $node->{$nodeName}; + } elseif ($defaultProvided) { + $this->{$propName} = $default; + } + } + + return $this; + } +} diff --git a/lib/OfxParser/Ofx/Investment.php b/lib/OfxParser/Ofx/Investment.php index 237359a..0220792 100644 --- a/lib/OfxParser/Ofx/Investment.php +++ b/lib/OfxParser/Ofx/Investment.php @@ -14,6 +14,8 @@ use OfxParser\Entities\Investment\Transaction\Income; use OfxParser\Entities\Investment\Transaction\Reinvest; use OfxParser\Entities\Investment\Transaction\SellMutualFund; +use OfxParser\Entities\Investment\Transaction\SellSecurity; +use OfxParser\Entities\Investment\Transaction\SellStock; class Investment extends Ofx { @@ -68,17 +70,22 @@ protected function buildAccount($transactionUid, SimpleXMLElement $statementResp $account->statement = new Statement(); $account->statement->currency = (string) $statementResponse->CURDEF; - $account->statement->startDate = Utils::createDateTimeFromStr( - $statementResponse->INVTRANLIST->DTSTART - ); - - $account->statement->endDate = Utils::createDateTimeFromStr( - $statementResponse->INVTRANLIST->DTEND - ); - - $account->statement->transactions = $this->buildTransactions( - $statementResponse->INVTRANLIST->children() - ); + if (@count($statementResponse->INVTRANLIST)) { + $account->statement->startDate = Utils::createDateTimeFromStr( + $statementResponse->INVTRANLIST->DTSTART + ); + + $account->statement->endDate = Utils::createDateTimeFromStr( + $statementResponse->INVTRANLIST->DTEND + ); + + $account->statement->transactions = $this->buildTransactions( + $statementResponse->INVTRANLIST->children() + ); + } else { + // Shouldn't this just be the default value in the Entity? + $account->statement->transactions = []; + } return $account; } @@ -120,6 +127,12 @@ protected function buildTransactions(SimpleXMLElement $transactions) case 'SELLMF': $item = new SellMutualFund(); break; + case 'SELLOTHER': + $item = new SellSecurity(); + break; + case 'SELLSTOCK': + $item = new SellStock(); + break; case 'DTSTART': // already processed break; diff --git a/lib/OfxParser/Parser.php b/lib/OfxParser/Parser.php index a5394fe..e0caf3b 100644 --- a/lib/OfxParser/Parser.php +++ b/lib/OfxParser/Parser.php @@ -60,7 +60,6 @@ public function loadFromString($ofxContent) if (stripos($ofxHeader, 'conditionallyAddNewlines($ofxSgml); $ofxXml = $this->convertSgmlToXml($ofxSgml); } @@ -72,21 +71,6 @@ public function loadFromString($ofxContent) return $ofx; } - /** - * Detect if the OFX file is on one line. If it is, add newlines automatically. - * - * @param string $ofxContent - * @return string - */ - private function conditionallyAddNewlines($ofxContent) - { - if (preg_match('/.*<\/OFX>/', $ofxContent) === 1) { - return str_replace('<', "\n<", $ofxContent); // add line breaks to allow XML to parse - } - - return $ofxContent; - } - /** * Load an XML string without PHP errors - throws exception instead * @@ -107,33 +91,6 @@ private function xmlLoadString($xmlString) return $xml; } - /** - * Detect any unclosed XML tags - if they exist, close them - * - * @param string $line - * @return string - */ - private function closeUnclosedXmlTags($line) - { - // Special case discovered where empty content tag wasn't closed - $line = trim($line); - if (preg_match('/$/', $line) === 1) { - return ''; - } - - // Matches: blah - // Does not match: - // Does not match: blah - if (preg_match( - "/<([A-Za-z0-9.]+)>([\wà-úÀ-Ú0-9\.\-\_\+\, ;:\[\]\'\&\/\\\*\(\)\+\{\|\}\!\£\$\?=@€£#%±§~`\"]+)$/", - $line, - $matches - )) { - return "<{$matches[1]}>{$matches[2]}"; - } - return $line; - } - /** * Parse the SGML Header to an Array * @@ -155,10 +112,13 @@ private function parseHeader($ofxHeader) // Only parse OFX headers and not XML headers. $ofxHeader = preg_replace('/<\?xml .*?\?>\n?/', '', $ofxHeader); $ofxHeader = preg_replace(['/"/', '/\?>/', '/<\?OFX/i'], '', $ofxHeader); + + // !', "\n<\\1>", $sgml); + + // Turn all special characters into ampersand? $sgml = preg_replace('/&(?!#?[a-z0-9]+;)/', '&', $sgml); $lines = explode("\n", $sgml); $tags = []; foreach ($lines as $i => &$line) { - $line = trim($this->closeUnclosedXmlTags($line)) . "\n"; + $line = trim($line) . "\n"; // Matches tags like or - if (!preg_match("/^<(\/?[A-Za-z0-9.]+)>$/", trim($line), $matches)) { + if (!preg_match("!^<(/?[A-Za-z0-9.]+)>(.*)$!", trim($line), $matches)) { continue; } - // If matches , looks back and replaces all tags like - // to until finds the opening tag - if ($matches[1][0] == '/') { + // If matches , looks back and closes all unmatched tags like + // VAL to VAL until finds the opening tag + if ($matches[1][0] == '/') { // If a closing tag... $tag = substr($matches[1], 1); while (($last = array_pop($tags)) && $last[1] != $tag) { - $lines[$last[0]] = "<{$last[1]}/>"; + $lines[$last[0]] = "<{$last[1]}>{$last[2]}"; } } else { - $tags[] = [$i, $matches[1]]; + $tags[] = [$i, $matches[1], $matches[2]]; + } + } + + // Clean up by closing any remaining tags + if ($tags) { + while ($last = array_pop($tags)) { + $lines[] = ""; } } - return implode("\n", array_map('trim', $lines)); + // Jam all our corrected lines into one happy line again! + // Then break out open tags for more readable/testable XML + $ret = implode('', array_map('trim', $lines)); + $ret = trim(preg_replace('!<([^/][A-Za-z0-9.]*)>!', "\n<\\1>", $ret)); + + // Finally, break out multiple close tags on the same line + while (preg_match('!(){2}!', $ret) === 1) { + $ret = preg_replace('!()()!', "\\1\n\\2", $ret); + } + + return $ret; } } diff --git a/lib/OfxParser/Utils.php b/lib/OfxParser/Utils.php index 435192b..d45273c 100644 --- a/lib/OfxParser/Utils.php +++ b/lib/OfxParser/Utils.php @@ -88,24 +88,25 @@ public static function createDateTimeFromStr($dateString, $ignoreErrors = false) */ public static function createAmountFromStr($amountString) { + // This assumes that all supported currency will have no more than + // 2 decimal places! The tell is the thousands separator, followed + // by three digits. If no thousands separator present, the only + // differentiator is number of decimal places. + // Decimal mark style (UK/US): 000.00 or 0,000.00 - if (preg_match('/^(-|\+)?([\d,]+)(\.?)([\d]{2})$/', $amountString) === 1) { - return (float)preg_replace( - ['/([,]+)/', '/\.?([\d]{2})$/'], - ['', '.$1'], - $amountString - ); + if (preg_match('/(\d,\d{3}|\.\d{1,2}$)/', $amountString) === 1) { + return (float) str_replace(',', '', $amountString); } // European style: 000,00 or 0.000,00 - if (preg_match('/^(-|\+)?([\d\.]+,?[\d]{2})$/', $amountString) === 1) { - return (float)preg_replace( - ['/([\.]+)/', '/,?([\d]{2})$/'], - ['', '.$1'], + if (preg_match('/(\d\.\d{3}|,\d{1,2}$)/', $amountString) === 1) { + return (float) str_replace( + array('.', ','), + array('', '.'), $amountString ); } - return (float)$amountString; + return (float) $amountString; } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b93618e..7c6c98a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,6 @@ - - ./tests - - - - - ./lib - - + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> + + + ./lib + + + + + ./tests + - diff --git a/tests/OfxParser/Entities/InvestmentTest.php b/tests/OfxParser/Entities/InvestmentTest.php index 37a3462..a95be17 100644 --- a/tests/OfxParser/Entities/InvestmentTest.php +++ b/tests/OfxParser/Entities/InvestmentTest.php @@ -59,10 +59,11 @@ public function loadOfx(SimpleXMLElement $node) class InvestmentTest extends TestCase { /** - * @expectedException \Exception + * This should throw an exception for missing an ofx node. */ public function testLoadOfxException() { + $this->expectException(\Exception::class); $xml = new SimpleXMLElement(''); $entity = new InvestmentNoLoadOfx(); $entity->loadOfx($xml); diff --git a/tests/OfxParser/Entities/LoaderTraitTest.php b/tests/OfxParser/Entities/LoaderTraitTest.php new file mode 100644 index 0000000..fc1afe9 --- /dev/null +++ b/tests/OfxParser/Entities/LoaderTraitTest.php @@ -0,0 +1,120 @@ + $this->public1, + 'protected1' => $this->protected1, + 'private1' => $this->private1, + ]; + } +} + + +/** + * @covers OfxParser\Entities\LoaderTrait + */ +class LoaderTraitTest extends TestCase +{ + /** + * Let's try all the things + */ + public function testLoadMap() + { + $testXML = new SimpleXMLElement(' + + LoaderTrait + Trait + 1234 + + '); + + $tests = [ + 'I can set values for any visibility' => [ + 'input' => [ + 'map' => [ + 'public1' => 'name', + 'protected1' => 'type', + 'private1' => 'num-prop' + ], + ], + 'expected' => [ + 'properties' => [ + 'public1' => 'LoaderTrait', + 'protected1' => 'Trait', + 'private1' => '1234', + ], + ], + ], + 'I ignore a default value if the node exists' => [ + 'input' => [ + 'map' => [ + 'public1' => ['name', 'Traitzzz'] + ], + ], + 'expected' => [ + 'properties' => [ + 'public1' => 'LoaderTrait', + 'protected1' => null, + 'private1' => null, + ], + ], + ], + 'I accept the default value when the node does not exist' => [ + 'input' => [ + 'map' => [ + 'public1' => ['namezzz', 'Traitzzz'] + ], + ], + 'expected' => [ + 'properties' => [ + 'public1' => 'Traitzzz', + 'protected1' => null, + 'private1' => null, + ], + ], + ], + ]; + + foreach ($tests as $testName => $data) { + $input = $data['input']; + $expected = $data['expected']; + + $testObj = new LoaderTraitContainer(); + $actual = $testObj->loadMap($input['map'], $testXML)->getProps(); + + $this->assertEquals($actual, $expected['properties'], $testName . ' failed!'); + } + } +} diff --git a/tests/OfxParser/OfxTest.php b/tests/OfxParser/OfxTest.php index a73256b..5d90d31 100644 --- a/tests/OfxParser/OfxTest.php +++ b/tests/OfxParser/OfxTest.php @@ -15,7 +15,7 @@ class OfxTest extends TestCase */ protected $ofxData; - public function setUp() + protected function setUp(): void { $ofxFile = dirname(__DIR__).'/fixtures/ofxdata-xml.ofx'; diff --git a/tests/OfxParser/ParserTest.php b/tests/OfxParser/ParserTest.php index e207ed3..8545a6a 100644 --- a/tests/OfxParser/ParserTest.php +++ b/tests/OfxParser/ParserTest.php @@ -92,39 +92,10 @@ public function testXmlLoadStringLoadsValidXml() /** * @return array */ - public function closeUnclosedXmlTagsProvider() - { - return [ - ['', ''], - ['foo', 'foo'], - ['foo', 'foo'], - ['XXXXX', 'XXXXX'], - ['XXXXXXXXXXX', 'XXXXXXXXXXX'], - ['-198.98', '-198.98'], - ['-198.98', '-198.98'], - ['', ''], - ]; - } - - /** - * @dataProvider closeUnclosedXmlTagsProvider - * @param $expected - * @param $input - */ - public function testCloseUnclosedXmlTags($expected, $input) - { - $method = new \ReflectionMethod(Parser::class, 'closeUnclosedXmlTags'); - $method->setAccessible(true); - - $parser = new Parser(); - - self::assertEquals($expected, $method->invoke($parser, $input)); - } - public function convertSgmlToXmlProvider() { return [ - [<< [<< bar bat @@ -136,7 +107,21 @@ public function convertSgmlToXmlProvider() bat HERE - ], [<< [<< + bar & restaurant + bat + +HERE + , << +bar & restaurant +bat + +HERE + ], + 'everything matching from the start' => [<< XXXXX XXXXX @@ -152,17 +137,37 @@ public function convertSgmlToXmlProvider() CHECKING HERE - ],[<< - bar & restaurant - bat - + ], + 'empty memo tag outlier' => [<< HERE - , << -bar & restaurant -bat - + , << + + +HERE + ], + 'empty container' => [<< +HERE + , << +HERE + ], + 'container with value, missing closing tag' => [<<foo +HERE + , <<foo +HERE + ], + 'nested container with negative value, missing closing tag' => [<<-198.98 +HERE + , << +-198.98 + HERE ], ]; diff --git a/tests/OfxParser/Parsers/InvestmentTest.php b/tests/OfxParser/Parsers/InvestmentTest.php index 54be402..04e53c7 100644 --- a/tests/OfxParser/Parsers/InvestmentTest.php +++ b/tests/OfxParser/Parsers/InvestmentTest.php @@ -60,8 +60,8 @@ public function testParseInvestmentsXML() 'settlementDate' => new \DateTime('2011-02-01'), 'securityId' => '822722622', 'securityIdType' => 'CUSIP', - 'units' => '', - 'unitPrice' => '', + 'units' => null, // Not part of INCOME definition + 'unitPrice' => null, // Not part of INCOME definition 'total' => '12.59', 'incomeType' => 'DIV', 'subAccountSec' => 'CASH', @@ -78,7 +78,7 @@ public function testParseInvestmentsXML() 'total' => '-6.97', 'incomeType' => 'DIV', 'subAccountSec' => 'CASH', - 'subAccountFund' => '', + 'subAccountFund' => null, // Not part of REINVEST definition 'actionCode' => 'REINVEST', ), '300100' => array( @@ -159,7 +159,7 @@ public function testParseInvestmentsXMLMultipleAccounts() 'total' => '-6.97', 'incomeType' => 'DIV', 'subAccountSec' => 'CASH', - 'subAccountFund' => '', + 'subAccountFund' => null, // Not a required node 'actionCode' => 'REINVEST', ), ), diff --git a/tests/OfxParser/UtilsTest.php b/tests/OfxParser/UtilsTest.php index 3057d88..3a068fe 100644 --- a/tests/OfxParser/UtilsTest.php +++ b/tests/OfxParser/UtilsTest.php @@ -18,23 +18,37 @@ class UtilsTest extends TestCase public function amountConversionProvider() { return [ - '1000.00' => ['1000.00', 1000.0], - '1000,00' => ['1000,00', 1000.0], - '1,000.00' => ['1,000.00', 1000.0], - '1.000,00' => ['1.000,00', 1000.0], - '-1000.00' => ['-1000.00', -1000.0], - '-1000,00' => ['-1000,00', -1000.0], - '-1,000.00' => ['-1,000.00', -1000.0], - '-1.000,00' => ['-1.000,00', -1000.0], '1' => ['1', 1.0], '10' => ['10', 10.0], - '100' => ['100', 1.0], // @todo this is weird behaviour, should not really expect this + '100' => ['100', 100.0], + '1000.01' => ['1000.01', 1000.01], + '1000,01' => ['1000,01', 1000.01], + '1,000.01' => ['1,000.01', 1000.01], + '1.000,01' => ['1.000,01', 1000.01], + '-1' => ['-1', -1.0], + '-10' => ['-10', -10.0], + '-100' => ['-100', -100.0], + '-1000.01' => ['-1000.01', -1000.01], + '-1000,01' => ['-1000,01', -1000.01], + '-1,000.01' => ['-1,000.01', -1000.01], + '-1.000,01' => ['-1.000,01', -1000.01], '+1' => ['+1', 1.0], '+10' => ['+10', 10.0], - '+1000.00' => ['+1000.00', 1000.0], - '+1000,00' => ['+1000,00', 1000.0], - '+1,000.00' => ['+1,000.00', 1000.0], - '+1.000,00' => ['+1.000,00', 1000.0], + '+100' => ['+100', 100.0], + '+1000.01' => ['+1000.01', 1000.01], + '+1000,01' => ['+1000,01', 1000.01], + '+1,000.01' => ['+1,000.01', 1000.01], + '+1.000,01' => ['+1.000,01', 1000.01], + + // Try some bigger numbers, too. + '2,225,000' => ['2,225,000', 2225000.00], + '2,225,000.01' => ['2,225,000.01', 2225000.01], + '2.225.000' => ['2.225.000', 2225000.00], + '2.225.000,01' => ['2.225.000,01', 2225000.01], + + // And some tiny numbers. + '0.02' => ['0.02', 0.02], + '-,03' => ['-,03', -0.03], ]; }