diff --git a/README.md b/README.md index 084dd84..daa2f64 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,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) -OFX Parser is a PHP library designed to parse an OFX file downloaded from a financial institution into simple PHP objects. +OFX Parser is a PHP library designed to parse an OFX file downloaded from a financial institution into simple PHP objects. It supports multiple Bank Accounts, the required "Sign On" response, and recognises OFX timestamps. @@ -35,6 +35,48 @@ $transactions = $bankAccount->statement->transactions; Most common nodes are support. If you come across an inaccessible node in your OFX file, please submit a pull request! +## Investments Support + +Investments look much different than bank / credit card transactions. This version supports a subset of the nodes in the OFX 2.0.3 spec, per the immediate needs of the author(s). You may want to reference the OFX documentation if you choose to implement this library. + +This is not a pure pass-through of fields, such as this implementation in python: [csingley/ofxtools](https://github.com/csingley/ofxtools). This package contains fields that have been "translated" on occasion to make it more friendly to those less-familiar with the investments OFX spec. + +To load investments from a Quicken (QFX) file or a MS Money (OFX / XML) file: + +```php +// You'll probably want to alias the namespace: +use OfxParser\Entities\Investment as InvEntities; + +// Load the OFX file +$ofxParser = new \OfxParser\Parser(); +$ofx = $ofxParser->loadFromFile('/path/to/your/investments_file.ofx'); + +// Loop over investment accounts (named bankAccounts from base lib) +foreach ($ofx->bankAccounts as $accountData) { + // Loop over transactions + foreach ($accountData->statement->transactions as $ofxEntity) { + // Keep in mind... not all properties are inherited for all transaction types... + + // Maybe you'll want to do something based on the transaction properties: + $nodeName = $ofxEntity->nodeName; + if ($nodeName == 'BUYSTOCK') { + // @see OfxParser\Entities\Investment\Transaction... + + $amount = abs($ofxEntity->total); + $cusip = $ofxEntity->securityId; + + // ... + } + + // Maybe you'll want to do something based on the entity: + if ($ofxEntity instanceof InvEntities\Transaction\BuyStock) { + // ... + } + + } +} +``` + ## 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! diff --git a/lib/OfxParser/Entities/Inspectable.php b/lib/OfxParser/Entities/Inspectable.php new file mode 100644 index 0000000..340f7b6 --- /dev/null +++ b/lib/OfxParser/Entities/Inspectable.php @@ -0,0 +1,15 @@ + 'prop_name', ...) + */ + public function getProperties(); +} diff --git a/lib/OfxParser/Entities/Investment.php b/lib/OfxParser/Entities/Investment.php new file mode 100644 index 0000000..e6e3329 --- /dev/null +++ b/lib/OfxParser/Entities/Investment.php @@ -0,0 +1,36 @@ + 'prop_name', ...) + */ + public function getProperties() + { + $props = array_keys(get_object_vars($this)); + + return array_combine($props, $props); + } + + /** + * All Investment entities require a loadOfx method. + * @param SimpleXMLElement $node + * @return $this For chaining + * @throws \Exception + */ + public function loadOfx(SimpleXMLElement $node) + { + throw new \Exception('loadOfx method not defined in class "' . get_class() . '"'); + } +} + diff --git a/lib/OfxParser/Entities/Investment/Account.php b/lib/OfxParser/Entities/Investment/Account.php new file mode 100644 index 0000000..eecb057 --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Account.php @@ -0,0 +1,29 @@ + node + * (aka "Banking aggregate") with an additional node. + * + * Requires Inspectable interface to match API of Invesetment entities + * extending OfxParser\Entities\Investment. + */ +class Banking extends BaseTransaction implements OfxLoadable, Inspectable +{ + /** + * @var string + */ + public $nodeName = 'INVBANKTRAN'; + + /** + * @var string + */ + public $subAccountFund; + + /** + * Get a list of properties defined for this entity. + * @return array array('prop_name' => 'prop_name', ...) + */ + public function getProperties() + { + $props = array_keys(get_object_vars($this)); + + return array_combine($props, $props); + } + + /** + * Imports the OFX data for this node. + * @param SimpleXMLElement $node + * @return $this + */ + public function loadOfx(SimpleXMLElement $node) + { + // Duplication of code in Ofx::buildTransactions() + $this->type = (string) $node->STMTTRN->TRNTYPE; + $this->date = Utils::createDateTimeFromStr($node->STMTTRN->DTPOSTED); + $this->amount = Utils::createAmountFromStr($node->STMTTRN->TRNAMT); + $this->uniqueId = (string) $node->STMTTRN->FITID; + + // Could put this in another trait. + $this->subAccountFund = (string) $node->SUBACCTFUND; + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/BuyMutualFund.php b/lib/OfxParser/Entities/Investment/Transaction/BuyMutualFund.php new file mode 100644 index 0000000..66654ac --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/BuyMutualFund.php @@ -0,0 +1,26 @@ +/ + * + * Same as BUYSTOCK, plus property. + */ +class BuyMutualFund extends BuyStock +{ + /** + * @var string + */ + public $nodeName = 'BUYMF'; + + /** + * RELFITID used to relate transactions associated with mutual fund exchanges. + * @var string + */ + public $relatedUniqueId; +} + diff --git a/lib/OfxParser/Entities/Investment/Transaction/BuySecurity.php b/lib/OfxParser/Entities/Investment/Transaction/BuySecurity.php new file mode 100644 index 0000000..33ebdb0 --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/BuySecurity.php @@ -0,0 +1,63 @@ +/ + * + * 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. + */ +class BuySecurity extends Investment +{ + /** + * Traits used to define properties + */ + use InvTran; + use SecId; + use Pricing; + + /** + * @var string + */ + public $nodeName = 'BUYOTHER'; + + /** + * Imports the OFX data for this node. + * @param SimpleXMLElement $node + * @return $this + */ + public function loadOfx(SimpleXMLElement $node) + { + // Transaction data is nested within child node + $this->loadInvTran($node->INVBUY->INVTRAN) + ->loadSecId($node->INVBUY->SECID) + ->loadPricing($node->INVBUY); + + return $this; + } +} + diff --git a/lib/OfxParser/Entities/Investment/Transaction/BuyStock.php b/lib/OfxParser/Entities/Investment/Transaction/BuyStock.php new file mode 100644 index 0000000..c60d17a --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/BuyStock.php @@ -0,0 +1,40 @@ +/ + * + * Properties found in the aggregate, + * plus property. + */ +class BuyStock extends BuySecurity +{ + /** + * Traits used to define properties + */ + use BuyType; + + /** + * @var string + */ + public $nodeName = 'BUYSTOCK'; + + /** + * Imports the OFX data for this node. + * @param SimpleXMLElement $node + * @return $this + */ + public function loadOfx(SimpleXMLElement $node) + { + parent::loadOfx($node); + $this->loadBuyType($node); + + return $this; + } +} + diff --git a/lib/OfxParser/Entities/Investment/Transaction/Income.php b/lib/OfxParser/Entities/Investment/Transaction/Income.php new file mode 100644 index 0000000..66e0eb3 --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Income.php @@ -0,0 +1,60 @@ +/ + * + * Required: + * aggregate + * aggregate + * + * + * + * + * + * Optional: + * ...many... + * + * Partial implementation. + */ +class Income extends Investment +{ + /** + * Traits used to define properties + */ + use IncomeType; + use InvTran; + use Pricing; // Not all of these are required for this node + use SecId; + + /** + * @var string + */ + public $nodeName = 'INCOME'; + + /** + * Imports the OFX data for this node. + * @param SimpleXMLElement $node + * @return $this + */ + public function loadOfx(SimpleXMLElement $node) + { + // Transaction data is in the root + $this->loadInvTran($node->INVTRAN) + ->loadSecId($node->SECID) + ->loadPricing($node) + ->loadIncomeType($node); + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/Reinvest.php b/lib/OfxParser/Entities/Investment/Transaction/Reinvest.php new file mode 100644 index 0000000..4e5900e --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Reinvest.php @@ -0,0 +1,11 @@ +/ + * + * , plus property. + */ +class SellMutualFund extends SellStock +{ + /** + * @var string + */ + public $nodeName = 'SELLMF'; + + /** + * RELFITID used to relate transactions associated with mutual fund exchanges. + * @var string + */ + public $relatedUniqueId; +} + diff --git a/lib/OfxParser/Entities/Investment/Transaction/SellSecurity.php b/lib/OfxParser/Entities/Investment/Transaction/SellSecurity.php new file mode 100644 index 0000000..82e3f9b --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/SellSecurity.php @@ -0,0 +1,63 @@ +/ + * + * 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. + */ +class SellSecurity extends Investment +{ + /** + * Traits used to define properties + */ + use InvTran; + use SecId; + use Pricing; + + /** + * @var string + */ + public $nodeName = 'SELLOTHER'; + + /** + * Imports the OFX data for this node. + * @param SimpleXMLElement $node + * @return $this + */ + public function loadOfx(SimpleXMLElement $node) + { + // Transaction data is nested within child node + $this->loadInvTran($node->INVSELL->INVTRAN) + ->loadSecId($node->INVSELL->SECID) + ->loadPricing($node->INVSELL); + + return $this; + } +} + diff --git a/lib/OfxParser/Entities/Investment/Transaction/SellStock.php b/lib/OfxParser/Entities/Investment/Transaction/SellStock.php new file mode 100644 index 0000000..38886e6 --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/SellStock.php @@ -0,0 +1,40 @@ +/ + * + * Properties found in the aggregate, + * plus property. + */ +class SellStock extends SellSecurity +{ + /** + * Traits used to define properties + */ + use SellType; + + /** + * @var string + */ + public $nodeName = 'SELLSTOCK'; + + /** + * Imports the OFX data for this node. + * @param SimpleXMLElement $node + * @return $this + */ + public function loadOfx(SimpleXMLElement $node) + { + parent::loadOfx($node); + $this->loadSellType($node); + + return $this; + } +} + diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/BuyType.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/BuyType.php new file mode 100644 index 0000000..d689e0b --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/BuyType.php @@ -0,0 +1,25 @@ +buyType = (string) $node->BUYTYPE; + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/IncomeType.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/IncomeType.php new file mode 100644 index 0000000..f249d4b --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/IncomeType.php @@ -0,0 +1,30 @@ +incomeType = (string) $node->INCOMETYPE; + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/InvTran.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/InvTran.php new file mode 100644 index 0000000..c573170 --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/InvTran.php @@ -0,0 +1,61 @@ + + * + * Limited implementation + */ +trait InvTran +{ + /** + * This is the unique identifier in the broker's system, + * NOT to be confused with the UNIQUEID node for the security. + * @var string + */ + public $uniqueId; + + /** + * Date the trade occurred + * @var \DateTimeInterface + */ + public $tradeDate; + + /** + * Date the trade was settled + * @var \DateTimeInterface + */ + public $settlementDate; + + /** + * Transaction memo, as provided from broker. + * @var string + */ + public $memo; + + /** + * @param SimpleXMLElement $node + * @return $this for chaining + */ + protected function loadInvTran(SimpleXMLElement $node) + { + // + // - REQUIRED: , + // - all others optional + $this->uniqueId = (string) $node->FITID; + $this->tradeDate = Utils::createDateTimeFromStr($node->DTTRADE); + if (isset($node->DTSETTLE)) { + $this->settlementDate = Utils::createDateTimeFromStr($node->DTSETTLE); + } + if (isset($node->MEMO)) { + $this->memo = (string) $node->MEMO; + } + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/Pricing.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/Pricing.php new file mode 100644 index 0000000..d57cc96 --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/Pricing.php @@ -0,0 +1,55 @@ +units = (string) $node->UNITS; + $this->unitPrice = (string) $node->UNITPRICE; + $this->total = (string) $node->TOTAL; + $this->subAccountFund = (string) $node->SUBACCTFUND; + $this->subAccountSec = (string) $node->SUBACCTSEC; + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/SecId.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/SecId.php new file mode 100644 index 0000000..9bb3308 --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/SecId.php @@ -0,0 +1,38 @@ + + */ +trait SecId +{ + /** + * Identifier for the security being traded. + * @var string + */ + public $securityId; + + /** + * The type of identifier for the security being traded. + * @var string + */ + public $securityIdType; + + /** + * @param SimpleXMLElement $node + * @return $this for chaining + */ + protected function loadSecId(SimpleXMLElement $node) + { + // + // - REQUIRED: , + $this->securityId = (string) $node->UNIQUEID; + $this->securityIdType = (string) $node->UNIQUEIDTYPE; + + return $this; + } +} diff --git a/lib/OfxParser/Entities/Investment/Transaction/Traits/SellType.php b/lib/OfxParser/Entities/Investment/Transaction/Traits/SellType.php new file mode 100644 index 0000000..959c4fd --- /dev/null +++ b/lib/OfxParser/Entities/Investment/Transaction/Traits/SellType.php @@ -0,0 +1,25 @@ +sellType = (string) $node->SELLTYPE; + + return $this; + } +} diff --git a/lib/OfxParser/Entities/OfxLoadable.php b/lib/OfxParser/Entities/OfxLoadable.php new file mode 100644 index 0000000..8121eb4 --- /dev/null +++ b/lib/OfxParser/Entities/OfxLoadable.php @@ -0,0 +1,15 @@ +status = $this->buildStatus($xml->STATUS); - $signOn->date = $this->createDateTimeFromStr($xml->DTSERVER, true); + $signOn->date = Utils::createDateTimeFromStr($xml->DTSERVER, true); $signOn->language = $xml->LANGUAGE; $signOn->institute = new Institute(); @@ -185,7 +186,7 @@ private function buildBankAccount($transactionUid, SimpleXMLElement $statementRe $bankAccount->routingNumber = $statementResponse->BANKACCTFROM->BANKID; $bankAccount->accountType = $statementResponse->BANKACCTFROM->ACCTTYPE; $bankAccount->balance = $statementResponse->LEDGERBAL->BALAMT; - $bankAccount->balanceDate = $this->createDateTimeFromStr( + $bankAccount->balanceDate = Utils::createDateTimeFromStr( $statementResponse->LEDGERBAL->DTASOF, true ); @@ -193,11 +194,11 @@ private function buildBankAccount($transactionUid, SimpleXMLElement $statementRe $bankAccount->statement = new Statement(); $bankAccount->statement->currency = $statementResponse->CURDEF; - $bankAccount->statement->startDate = $this->createDateTimeFromStr( + $bankAccount->statement->startDate = Utils::createDateTimeFromStr( $statementResponse->BANKTRANLIST->DTSTART ); - $bankAccount->statement->endDate = $this->createDateTimeFromStr( + $bankAccount->statement->endDate = Utils::createDateTimeFromStr( $statementResponse->BANKTRANLIST->DTEND ); @@ -227,12 +228,12 @@ private function buildCreditAccount(SimpleXMLElement $xml) $creditAccount->routingNumber = $xml->CCSTMTRS->$nodeName->BANKID; $creditAccount->accountType = $xml->CCSTMTRS->$nodeName->ACCTTYPE; $creditAccount->balance = $xml->CCSTMTRS->LEDGERBAL->BALAMT; - $creditAccount->balanceDate = $this->createDateTimeFromStr($xml->CCSTMTRS->LEDGERBAL->DTASOF, true); + $creditAccount->balanceDate = Utils::createDateTimeFromStr($xml->CCSTMTRS->LEDGERBAL->DTASOF, true); $creditAccount->statement = new Statement(); $creditAccount->statement->currency = $xml->CCSTMTRS->CURDEF; - $creditAccount->statement->startDate = $this->createDateTimeFromStr($xml->CCSTMTRS->BANKTRANLIST->DTSTART); - $creditAccount->statement->endDate = $this->createDateTimeFromStr($xml->CCSTMTRS->BANKTRANLIST->DTEND); + $creditAccount->statement->startDate = Utils::createDateTimeFromStr($xml->CCSTMTRS->BANKTRANLIST->DTSTART); + $creditAccount->statement->endDate = Utils::createDateTimeFromStr($xml->CCSTMTRS->BANKTRANLIST->DTEND); $creditAccount->statement->transactions = $this->buildTransactions($xml->CCSTMTRS->BANKTRANLIST->STMTTRN); return $creditAccount; @@ -249,11 +250,11 @@ private function buildTransactions(SimpleXMLElement $transactions) foreach ($transactions as $t) { $transaction = new Transaction(); $transaction->type = (string)$t->TRNTYPE; - $transaction->date = $this->createDateTimeFromStr($t->DTPOSTED); + $transaction->date = Utils::createDateTimeFromStr($t->DTPOSTED); if ('' !== (string)$t->DTUSER) { - $transaction->userInitiatedDate = $this->createDateTimeFromStr($t->DTUSER); + $transaction->userInitiatedDate = Utils::createDateTimeFromStr($t->DTUSER); } - $transaction->amount = $this->createAmountFromStr($t->TRNAMT); + $transaction->amount = Utils::createAmountFromStr($t->TRNAMT); $transaction->uniqueId = (string)$t->FITID; $transaction->name = (string)$t->NAME; $transaction->memo = (string)$t->MEMO; @@ -278,88 +279,4 @@ private function buildStatus(SimpleXMLElement $xml) return $status; } - - /** - * Create a DateTime object from a valid OFX date format - * - * Supports: - * YYYYMMDDHHMMSS.XXX[gmt offset:tz name] - * YYYYMMDDHHMMSS.XXX - * YYYYMMDDHHMMSS - * YYYYMMDD - * - * @param string $dateString - * @param boolean $ignoreErrors - * @return \DateTime $dateString - * @throws \Exception - */ - private function createDateTimeFromStr($dateString, $ignoreErrors = false) - { - if((!isset($dateString) || trim($dateString) === '')) return null; - - $regex = '/' - . "(\d{4})(\d{2})(\d{2})?" // YYYYMMDD 1,2,3 - . "(?:(\d{2})(\d{2})(\d{2}))?" // HHMMSS - optional 4,5,6 - . "(?:\.(\d{3}))?" // .XXX - optional 7 - . "(?:\[(-?\d+)\:(\w{3}\]))?" // [-n:TZ] - optional 8,9 - . '/'; - - if (preg_match($regex, $dateString, $matches)) { - $year = (int)$matches[1]; - $month = (int)$matches[2]; - $day = (int)$matches[3]; - $hour = isset($matches[4]) ? $matches[4] : 0; - $min = isset($matches[5]) ? $matches[5] : 0; - $sec = isset($matches[6]) ? $matches[6] : 0; - - $format = $year . '-' . $month . '-' . $day . ' ' . $hour . ':' . $min . ':' . $sec; - - try { - return new \DateTime($format); - } catch (\Exception $e) { - if ($ignoreErrors) { - return null; - } - - throw $e; - } - } - - throw new \RuntimeException('Failed to initialize DateTime for string: ' . $dateString); - } - - /** - * Create a formatted number in Float according to different locale options - * - * Supports: - * 000,00 and -000,00 - * 0.000,00 and -0.000,00 - * 0,000.00 and -0,000.00 - * 000.00 and 000.00 - * - * @param string $amountString - * @return float - */ - private function createAmountFromStr($amountString) - { - // 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 - ); - } - - // European style: 000,00 or 0.000,00 - if (preg_match('/^(-|\+)?([\d\.]+,?[\d]{2})$/', $amountString) === 1) { - return (float)preg_replace( - ['/([\.]+)/', '/,?([\d]{2})$/'], - ['', '.$1'], - $amountString - ); - } - - return (float)$amountString; - } } diff --git a/lib/OfxParser/Ofx/Investment.php b/lib/OfxParser/Ofx/Investment.php new file mode 100644 index 0000000..237359a --- /dev/null +++ b/lib/OfxParser/Ofx/Investment.php @@ -0,0 +1,142 @@ +signOn = $this->buildSignOn($xml->SIGNONMSGSRSV1->SONRS); + + if (isset($xml->INVSTMTMSGSRSV1)) { + $this->bankAccounts = $this->buildAccounts($xml); + } + + // Set a helper if only one bank account + if (count($this->bankAccounts) === 1) { + $this->bankAccount = $this->bankAccounts[0]; + } + } + + /** + * @param SimpleXMLElement $xml + * @return array Array of InvestmentAccount enities + * @throws \Exception + */ + protected function buildAccounts(SimpleXMLElement $xml) + { + // Loop through the bank accounts + $accounts = []; + foreach ($xml->INVSTMTMSGSRSV1->INVSTMTTRNRS as $accountStatement) { + foreach ($accountStatement->INVSTMTRS as $statementResponse) { + $accounts[] = $this->buildAccount($accountStatement->TRNUID, $statementResponse); + } + } + return $accounts; + } + + /** + * @param string $transactionUid + * @param SimpleXMLElement $statementResponse + * @return InvestmentAccount + * @throws \Exception + */ + protected function buildAccount($transactionUid, SimpleXMLElement $statementResponse) + { + $account = new InvestmentAccount(); + $account->transactionUid = (string) $transactionUid; + $account->brokerId = (string) $statementResponse->INVACCTFROM->BROKERID; + $account->accountNumber = (string) $statementResponse->INVACCTFROM->ACCTID; + + $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() + ); + + return $account; + } + + /** + * Processes multiple types of investment transactions, ignoring many + * others. + * + * @param SimpleXMLElement $transactions + * @return array + * @throws \Exception + */ + protected function buildTransactions(SimpleXMLElement $transactions) + { + $activity = []; + + foreach ($transactions as $t) { + $item = null; + + switch ($t->getName()) { + case 'BUYMF': + $item = new BuyMutualFund(); + break; + case 'BUYOTHER': + $item = new BuySecurity(); + break; + case 'BUYSTOCK': + $item = new BuyStock(); + break; + case 'INCOME': + $item = new Income(); + break; + case 'INVBANKTRAN': + $item = new Banking(); + break; + case 'REINVEST': + $item = new Reinvest(); + break; + case 'SELLMF': + $item = new SellMutualFund(); + break; + case 'DTSTART': + // already processed + break; + case 'DTEND': + // already processed + break; + default: + // Log: ignored node.... + break; + } + + if (!is_null($item)) { + $item->loadOfx($t); + $activity[] = $item; + } + } + + return $activity; + } +} diff --git a/lib/OfxParser/Parser.php b/lib/OfxParser/Parser.php index 8638f47..1670238 100644 --- a/lib/OfxParser/Parser.php +++ b/lib/OfxParser/Parser.php @@ -2,6 +2,8 @@ namespace OfxParser; +use SimpleXMLElement; + /** * An OFX parser library * @@ -13,6 +15,16 @@ */ class Parser { + /** + * Factory to extend support for OFX document structures. + * @param SimpleXMLElement $xml + * @return Ofx + */ + protected function createOfx(SimpleXMLElement $xml) + { + return new Ofx($xml); + } + /** * Load an OFX file into this parser by way of a filename * @@ -40,21 +52,22 @@ public function loadFromString($ofxContent) { $ofxContent = str_replace(["\r\n", "\r"], "\n", $ofxContent); $ofxContent = utf8_encode($ofxContent); - $ofxContent = $this->conditionallyAddNewlines($ofxContent); $sgmlStart = stripos($ofxContent, ''); - - $ofxHeader = trim(substr($ofxContent, 0, $sgmlStart-1)); - + $ofxHeader = trim(substr($ofxContent, 0, $sgmlStart)); $header = $this->parseHeader($ofxHeader); $ofxSgml = trim(substr($ofxContent, $sgmlStart)); - - $ofxXml = $this->convertSgmlToXml($ofxSgml); + if (stripos($ofxHeader, 'conditionallyAddNewlines($ofxSgml); + $ofxXml = $this->convertSgmlToXml($ofxSgml); + } $xml = $this->xmlLoadString($ofxXml); - $ofx = new Ofx($xml); + $ofx = $this->createOfx($xml); $ofx->buildHeader($header); return $ofx; @@ -128,16 +141,16 @@ private function parseHeader($ofxHeader) $header = []; - $ofxHeader = trim($ofxHeader); + $ofxHeader = trim($ofxHeader); // Remove empty new lines. $ofxHeader = preg_replace('/^\n+/m', '', $ofxHeader); - $ofxHeaderLines = explode("\n", $ofxHeader); // Check if it's an XML file (OFXv2) if(preg_match('/^<\?xml/', $ofxHeader) === 1) { - $ofxHeaderLines = preg_replace(['/"/', '/\?>$/m', '/^(<\?)(XML|OFX)/mi'], '', $ofxHeaderLines); // Only parse OFX headers and not XML headers. - $ofxHeaderLine = explode(' ', trim($ofxHeaderLines[1])); + $ofxHeader = preg_replace('/<\?xml .*?\?>\n?/', '', $ofxHeader); + $ofxHeader = preg_replace(['/"/', '/\?>/', '/<\?OFX/i'], '', $ofxHeader); + $ofxHeaderLine = explode(' ', trim($ofxHeader)); foreach ($ofxHeaderLine as $value) { $tag = explode('=', $value); @@ -147,6 +160,7 @@ private function parseHeader($ofxHeader) return $header; } + $ofxHeaderLines = explode("\n", $ofxHeader); foreach ($ofxHeaderLines as $value) { $tag = explode(':', $value); $header[$tag[0]] = $tag[1]; diff --git a/lib/OfxParser/Parsers/Investment.php b/lib/OfxParser/Parsers/Investment.php new file mode 100644 index 0000000..4289b3c --- /dev/null +++ b/lib/OfxParser/Parsers/Investment.php @@ -0,0 +1,20 @@ + - ./tests/ + ./tests diff --git a/tests/OfxParser/Entities/InvestmentTest.php b/tests/OfxParser/Entities/InvestmentTest.php new file mode 100644 index 0000000..37a3462 --- /dev/null +++ b/tests/OfxParser/Entities/InvestmentTest.php @@ -0,0 +1,98 @@ +'); + $entity = new InvestmentNoLoadOfx(); + $entity->loadOfx($xml); + } + + /** + * If no exception thrown, we're good. + */ + public function testLoadOfxValid() + { + $xml = new SimpleXMLElement(''); + $entity = new InvestmentValid(); + $entity->loadOfx($xml); + $this->assertTrue(true); + } + + /** + * If no exception thrown, we're good. + */ + public function testGetProperties() + { + $expectedProps = array( + 'public1', + 'protected1', + ); + + $entity = new InvestmentValid(); + $actualProps = $entity->getProperties(); + + $this->assertSame($expectedProps, array_keys($actualProps)); + $this->assertSame($expectedProps, array_values($actualProps)); + } +} diff --git a/tests/OfxParser/OfxTest.php b/tests/OfxParser/OfxTest.php index 8691d97..a73256b 100644 --- a/tests/OfxParser/OfxTest.php +++ b/tests/OfxParser/OfxTest.php @@ -2,12 +2,13 @@ namespace OfxParserTest; +use PHPUnit\Framework\TestCase; use OfxParser\Ofx; /** * @covers OfxParser\Ofx */ -class OfxTest extends \PHPUnit_Framework_TestCase +class OfxTest extends TestCase { /** * @var \SimpleXMLElement @@ -24,76 +25,6 @@ public function setUp() $this->ofxData = simplexml_load_string(file_get_contents($ofxFile)); } - 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 - '+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], - ]; - } - - /** - * @param string $input - * @param float $output - * @dataProvider amountConversionProvider - */ - public function testCreateAmountFromStr($input, $output) - { - $method = new \ReflectionMethod(Ofx::class, 'createAmountFromStr'); - $method->setAccessible(true); - - $ofx = new Ofx($this->ofxData); - - self::assertSame($output, $method->invoke($ofx, $input)); - } - - public function testCreateDateTimeFromOFXDateFormats() - { - // October 5, 2008, at 1:22 and 124 milliseconds pm, Easter Standard Time - $expectedDateTime = new \DateTime('2008-10-05 13:22:00'); - - $method = new \ReflectionMethod(Ofx::class, 'createDateTimeFromStr'); - $method->setAccessible(true); - - $Ofx = new Ofx($this->ofxData); - - // Test OFX Date Format YYYYMMDDHHMMSS.XXX[gmt offset:tz name] - $DateTimeOne = $method->invoke($Ofx, '20081005132200.124[-5:EST]'); - self::assertEquals($expectedDateTime->getTimestamp(), $DateTimeOne->getTimestamp()); - - // Test YYYYMMDD - $DateTimeTwo = $method->invoke($Ofx, '20081005'); - self::assertEquals($expectedDateTime->format('Y-m-d'), $DateTimeTwo->format('Y-m-d')); - - // Test YYYYMMDDHHMMSS - $DateTimeThree = $method->invoke($Ofx, '20081005132200'); - self::assertEquals($expectedDateTime->getTimestamp(), $DateTimeThree->getTimestamp()); - - // Test YYYYMMDDHHMMSS.XXX - $DateTimeFour = $method->invoke($Ofx, '20081005132200.124'); - self::assertEquals($expectedDateTime->getTimestamp(), $DateTimeFour->getTimestamp()); - - // Test empty datetime - $DateTimeFour = $method->invoke($Ofx, ''); - self::assertEquals(null, $DateTimeFour); - - } - public function testBuildsSignOn() { $ofx = new Ofx($this->ofxData); diff --git a/tests/OfxParser/ParserTest.php b/tests/OfxParser/ParserTest.php index ebacd5a..4f80471 100644 --- a/tests/OfxParser/ParserTest.php +++ b/tests/OfxParser/ParserTest.php @@ -2,12 +2,13 @@ namespace OfxParserTest; +use PHPUnit\Framework\TestCase; use OfxParser\Parser; /** * @covers OfxParser\Parser */ -class ParserTest extends \PHPUnit_Framework_TestCase +class ParserTest extends TestCase { public function testCreditCardStatementTransactionsAreLoaded() { @@ -65,6 +66,7 @@ public function testXmlLoadStringThrowsExceptionWithInvalidXml() $method->invoke(new Parser(), $invalidXml); } catch (\Exception $e) { if (stripos($e->getMessage(), 'Failed to parse OFX') !== false) { + $this->assertTrue(true); return true; } @@ -234,7 +236,14 @@ public function testLoadFromString($filename) $content = file_get_contents($filename); $parser = new Parser(); - $parser->loadFromString($content); + + try { + $parser->loadFromString($content); + } catch (\Exception $e) { + throw $e; + } + + $this->assertTrue(true); } public function testXmlLoadStringWithSelfClosingTag() diff --git a/tests/OfxParser/Parsers/InvestmentTest.php b/tests/OfxParser/Parsers/InvestmentTest.php new file mode 100644 index 0000000..3f725c9 --- /dev/null +++ b/tests/OfxParser/Parsers/InvestmentTest.php @@ -0,0 +1,248 @@ +loadFromFile(__DIR__ . '/../../fixtures/ofxdata-investments-xml.ofx'); + + $account = reset($ofx->bankAccounts); + self::assertSame('TEST-UID-1', $account->transactionUid); + self::assertSame('vanguard.com', $account->brokerId); + self::assertSame('1234567890', $account->accountNumber); + + // Check some transactions: + $expected = array( + '100100' => array( + 'tradeDate' => new \DateTime('2010-01-01'), + 'settlementDate' => new \DateTime('2010-01-02'), + 'securityId' => '122022322', + 'securityIdType' => 'CUSIP', + 'units' => '31.25', + 'unitPrice' => '32.0', + 'total' => '-1000.0', + 'buyType' => 'BUY', + 'actionCode' => 'BUYMF', + ), + '100200' => array( + 'tradeDate' => new \DateTime('2011-02-01'), + 'settlementDate' => new \DateTime('2011-02-03'), + 'securityId' => '355055155', + 'securityIdType' => 'CUSIP', + 'units' => '3.0', + 'unitPrice' => '181.96', + 'total' => '-545.88', + 'buyType' => 'BUY', + 'actionCode' => 'BUYSTOCK', + ), + '100300' => array( + 'tradeDate' => new \DateTime('2010-01-01'), + 'settlementDate' => new \DateTime('2010-01-01'), + 'securityId' => '122022322', + 'securityIdType' => 'CUSIP', + 'units' => '-1000.0', + 'unitPrice' => '1.0', + 'total' => '1000.0', + 'sellType' => 'SELL', + 'actionCode' => 'SELLMF', + ), + '200100' => array( + 'tradeDate' => new \DateTime('2011-02-01'), + 'settlementDate' => new \DateTime('2011-02-01'), + 'securityId' => '822722622', + 'securityIdType' => 'CUSIP', + 'units' => '', + 'unitPrice' => '', + 'total' => '12.59', + 'incomeType' => 'DIV', + 'subAccountSec' => 'CASH', + 'subAccountFund' => 'CASH', + 'actionCode' => 'INCOME', + ), + '200200' => array( + 'tradeDate' => new \DateTime('2011-02-01'), + 'settlementDate' => new \DateTime('2011-02-01'), + 'securityId' => '355055155', + 'securityIdType' => 'CUSIP', + 'units' => '0.037', + 'unitPrice' => '187.9894', + 'total' => '-6.97', + 'incomeType' => 'DIV', + 'subAccountSec' => 'CASH', + 'subAccountFund' => '', + 'actionCode' => 'REINVEST', + ), + '300100' => array( + 'date' => new \DateTime('2010-01-15'), + 'type' => 'OTHER', + 'amount' => 1234.56, + 'actionCode' => 'INVBANKTRAN', + ), + ); + + if (count($expected)) { + self::assertTrue(count($account->statement->transactions) > 0); + } + + foreach ($account->statement->transactions as $t) { + if (isset($expected[$t->uniqueId])) { + $data = $expected[$t->uniqueId]; + foreach ($data as $prop => $val) { + // TEMP: + if ($prop == 'actionCode') { + continue; + } + + if ($val instanceof \DateTimeInterface) { + self::assertSame( + $val->format('Y-m-d'), + $t->{$prop}->format('Y-m-d'), + 'Failed comparison for "' . $prop .'"' + ); + } else { + self::assertSame( + $val, + $t->{$prop}, + 'Failed comparison for "' . $prop .'"' + ); + } + } + } + } + } + + public function testParseInvestmentsXMLOneLine() + { + $parser = new InvestmentParser(); + $ofx = $parser->loadFromFile(__DIR__ . '/../../fixtures/ofxdata-investments-oneline-xml.ofx'); + + $account = reset($ofx->bankAccounts); + self::assertSame('TEST-UID-1', $account->transactionUid); + self::assertSame('vanguard.com', $account->brokerId); + self::assertSame('1234567890', $account->accountNumber); + + // Check some transactions: + $expected = array( + '100200' => array( + 'tradeDate' => new \DateTime('2011-02-01'), + 'settlementDate' => new \DateTime('2011-02-03'), + 'securityId' => '355055155', + 'securityIdType' => 'CUSIP', + 'units' => '3.0', + 'unitPrice' => '181.96', + 'total' => '-545.88', + 'buyType' => 'BUY', + 'actionCode' => 'BUYSTOCK', + ), + ); + + if (count($expected)) { + self::assertTrue(count($account->statement->transactions) > 0); + } + + foreach ($account->statement->transactions as $t) { + if (isset($expected[$t->uniqueId])) { + $data = $expected[$t->uniqueId]; + foreach ($data as $prop => $val) { + // TEMP: + if ($prop == 'actionCode') { + continue; + } + + if ($val instanceof \DateTimeInterface) { + self::assertSame( + $val->format('Y-m-d'), + $t->{$prop}->format('Y-m-d'), + 'Failed comparison for "' . $prop .'"' + ); + } else { + self::assertSame( + $val, + $t->{$prop}, + 'Failed comparison for "' . $prop .'"' + ); + } + } + } + } + } + + public function testParseInvestmentsXMLMultipleAccounts() + { + $parser = new InvestmentParser(); + $ofx = $parser->loadFromFile(__DIR__ . '/../../fixtures/ofxdata-investments-multiple-accounts-xml.ofx'); + + // Check some transactions: + $expected = array( + '1234567890' => array( + '100200' => array( + 'tradeDate' => new \DateTime('2011-02-01'), + 'settlementDate' => new \DateTime('2011-02-03'), + 'securityId' => '355055155', + 'securityIdType' => 'CUSIP', + 'units' => '3.0', + 'unitPrice' => '181.96', + 'total' => '-545.88', + 'buyType' => 'BUY', + 'actionCode' => 'BUYSTOCK', + ), + ), + '987654321' => array( + '200200' => array( + 'tradeDate' => new \DateTime('2011-02-01'), + 'settlementDate' => new \DateTime('2011-02-01'), + 'securityId' => '355055155', + 'securityIdType' => 'CUSIP', + 'units' => '0.037', + 'unitPrice' => '187.9894', + 'total' => '-6.97', + 'incomeType' => 'DIV', + 'subAccountSec' => 'CASH', + 'subAccountFund' => '', + 'actionCode' => 'REINVEST', + ), + ), + ); + + if (count($expected)) { + self::assertEquals(count($ofx->bankAccounts), count($expected), 'Account count mismatch'); + } + + foreach ($ofx->bankAccounts as $account) { + foreach ($account->statement->transactions as $t) { + if (isset($expected[$t->uniqueId])) { + $data = $expected[$t->uniqueId]; + foreach ($data as $prop => $val) { + // TEMP: + if ($prop == 'actionCode') { + continue; + } + + if ($val instanceof \DateTimeInterface) { + self::assertSame( + $val->format('Y-m-d'), + $t->{$prop}->format('Y-m-d'), + 'Failed comparison for "' . $prop .'"' + ); + } else { + self::assertSame( + $val, + $t->{$prop}, + 'Failed comparison for "' . $prop .'"' + ); + } + } + } + } + } + } +} diff --git a/tests/OfxParser/UtilsTest.php b/tests/OfxParser/UtilsTest.php new file mode 100644 index 0000000..3057d88 --- /dev/null +++ b/tests/OfxParser/UtilsTest.php @@ -0,0 +1,84 @@ + ['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 + '+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], + ]; + } + + /** + * @param string $input + * @param float $output + * @dataProvider amountConversionProvider + */ + public function testCreateAmountFromStr($input, $output) + { + $actual = Utils::createAmountFromStr($input); + self::assertSame($output, $actual); + } + + public function testCreateDateTimeFromOFXDateFormats() + { + // October 5, 2008, at 1:22 and 124 milliseconds pm, Easter Standard Time + $expectedDateTime = new \DateTime('2008-10-05 13:22:00'); + + // Test OFX Date Format YYYYMMDDHHMMSS.XXX[gmt offset:tz name] + $DateTimeOne = Utils::createDateTimeFromStr('20081005132200.124[-5:EST]'); + self::assertEquals($expectedDateTime->getTimestamp(), $DateTimeOne->getTimestamp()); + + // Test YYYYMMDD + $DateTimeTwo = Utils::createDateTimeFromStr('20081005'); + self::assertEquals($expectedDateTime->format('Y-m-d'), $DateTimeTwo->format('Y-m-d')); + + // Test YYYYMMDDHHMMSS + $DateTimeThree = Utils::createDateTimeFromStr('20081005132200'); + self::assertEquals($expectedDateTime->getTimestamp(), $DateTimeThree->getTimestamp()); + + // Test YYYYMMDDHHMMSS.XXX + $DateTimeFour = Utils::createDateTimeFromStr('20081005132200.124'); + self::assertEquals($expectedDateTime->getTimestamp(), $DateTimeFour->getTimestamp()); + + // Test empty datetime + $DateTimeFive = Utils::createDateTimeFromStr(''); + self::assertEquals(null, $DateTimeFive); + + // Test DateTime factory callback + Utils::$fnDateTimeFactory = function($format) { return new MyDateTime($format); }; + $DateTimeSix = Utils::createDateTimeFromStr('20081005'); + self::assertEquals($expectedDateTime->format('Y-m-d'), $DateTimeSix->format('Y-m-d')); + self::assertEquals('OfxParserTest\\MyDateTime', get_class($DateTimeSix)); + Utils::$fnDateTimeFactory = null; + } +} diff --git a/tests/fixtures/ofxdata-investments-multiple-accounts-xml.ofx b/tests/fixtures/ofxdata-investments-multiple-accounts-xml.ofx new file mode 100644 index 0000000..d67686d --- /dev/null +++ b/tests/fixtures/ofxdata-investments-multiple-accounts-xml.ofx @@ -0,0 +1,98 @@ + + + + + + + 0 + INFO + Successful Sign On + + 20120208160000 + ENG + 20100605083000 + + Vanguard + 15103 + + 0123456789abcdef + + + + + TEST-UID-1 + + 0 + INFO + + + 20120208160000.000[-5:EST] + USD + + vanguard.com + 1234567890 + + + 20090608160000.000[-5:EST] + 20120218160000.000[-5:EST] + + + + 100200 + 20110201160000.000[-5:EST] + 20110203160000.000[-5:EST] + BUY + + + 355055155 + CUSIP + + 3.0 + 181.96 + -545.88 + CASH + CASH + + BUY + + + + + + TEST-UID-1 + + 0 + INFO + + + 20120208160000.000[-5:EST] + USD + + vanguard.com + 987654321 + + + 20090608160000.000[-5:EST] + 20120218160000.000[-5:EST] + + + 200200 + 20110201160000.000[-5:EST] + 20110201160000.000[-5:EST] + DIVIDEND REINVESTMENT + + + 355055155 + CUSIP + + DIV + -6.97 + CASH + 0.037 + 187.9894 + + + + + + diff --git a/tests/fixtures/ofxdata-investments-oneline-xml.ofx b/tests/fixtures/ofxdata-investments-oneline-xml.ofx new file mode 100644 index 0000000..d4fcd97 --- /dev/null +++ b/tests/fixtures/ofxdata-investments-oneline-xml.ofx @@ -0,0 +1 @@ +0INFOSuccessful Sign On20120208160000ENG20100605083000Vanguard151030123456789abcdefTEST-UID-10INFO20120208160000.000[-5:EST]USDvanguard.com123456789020090608160000.000[-5:EST]20120218160000.000[-5:EST]10020020110201160000.000[-5:EST]20110203160000.000[-5:EST]BUY355055155CUSIP3.0181.96-545.88CASHCASHBUY diff --git a/tests/fixtures/ofxdata-investments-xml.ofx b/tests/fixtures/ofxdata-investments-xml.ofx new file mode 100644 index 0000000..1004fe2 --- /dev/null +++ b/tests/fixtures/ofxdata-investments-xml.ofx @@ -0,0 +1,144 @@ + + + + + + + 0 + INFO + Successful Sign On + + 20120208160000 + ENG + 20100605083000 + + Vanguard + 15103 + + 0123456789abcdef + + + + + TEST-UID-1 + + 0 + INFO + + + 20120208160000.000[-5:EST] + USD + + vanguard.com + 1234567890 + + + 20090608160000.000[-5:EST] + 20120218160000.000[-5:EST] + + + + 100100 + 20100101160000.000[-5:EST] + 20100102160000.000[-5:EST] + BUY + + + 122022322 + CUSIP + + 31.25 + 32.0 + -1000.0 + CASH + CASH + + BUY + + + + + 100200 + 20110201160000.000[-5:EST] + 20110203160000.000[-5:EST] + BUY + + + 355055155 + CUSIP + + 3.0 + 181.96 + -545.88 + CASH + CASH + + BUY + + + + 200100 + 20110201160000.000[-5:EST] + 20110201160000.000[-5:EST] + DIVIDEND PAYMENT + + + 822722622 + CUSIP + + DIV + 12.59 + CASH + CASH + + + + 200200 + 20110201160000.000[-5:EST] + 20110201160000.000[-5:EST] + DIVIDEND REINVESTMENT + + + 355055155 + CUSIP + + DIV + -6.97 + CASH + 0.037 + 187.9894 + + + + + 100300 + 20100101160000.000[-5:EST] + 20100101160000.000[-5:EST] + MONEY FUND REDEMPTION + + + 122022322 + CUSIP + + -1000.0 + 1.0 + 1000.0 + CASH + CASH + + SELL + + + + OTHER + 20100115160000.000[-5:EST] + 1234.56 + 300100 + + CASH + + + + + +