array( 'className' => 'StatementEntry', ), ); var $hasMany = array( // The payments that apply to this charge (if it is one) 'PaymentEntry' => array( 'className' => 'StatementEntry', 'foreignKey' => 'charge_entry_id', ), ); /************************************************************************** ************************************************************************** ************************************************************************** * function: chargePaymentFields */ function chargePaymentFields($sum = false, $entry_name = 'StatementEntry') { $fields = array ( ($sum ? 'SUM(' : '') . "IF({$entry_name}.type = 'CHARGE'," . " {$entry_name}.amount, NULL)" . ($sum ? ')' : '') . ' AS charge' . ($sum ? 's' : ''), ($sum ? 'SUM(' : '') . "IF({$entry_name}.type = 'PAYMENT' OR {$entry_name}.type = 'CREDIT'," . " {$entry_name}.amount, NULL)" . ($sum ? ')' : '') . ' AS payment' . ($sum ? 's' : ''), ($sum ? 'SUM(' : '') . "IF({$entry_name}.type = 'CHARGE', 1, -1)" . " * IF({$entry_name}.amount, {$entry_name}.amount, 0)" . ($sum ? ')' : '') . ' AS balance', ); if ($sum) $fields[] = "COUNT({$entry_name}.id) AS entries"; return $fields; } /************************************************************************** ************************************************************************** ************************************************************************** * function: verifyStatementEntry * - Verifies consistenty of new statement entry data * (not in a pre-existing statement entry) */ function verifyStatementEntry($entry) { /* pr(array("StatementEntry::verifyStatementEntry()" */ /* => compact('entry'))); */ if (empty($entry['type']) || //empty($entry['effective_date']) || empty($entry['account_id']) || empty($entry['amount']) ) { /* pr(array("StatementEntry::verifyStatementEntry()" */ /* => "Entry verification failed")); */ return false; } return true; } /************************************************************************** ************************************************************************** ************************************************************************** * function: addStatementEntry * - Inserts new Statement Entry into the database */ function addStatementEntry($entry) { /* pr(array('StatementEntry::addStatementEntry' => */ /* compact('entry'))); */ $ret = array(); if (!$this->verifyStatementEntry($entry)) return array('error' => true, 'verify_data' => $entry) + $ret; /* pr(array('StatementEntry::addStatementEntry' => */ /* array('checkpoint' => 'Pre-Save') */ /* + compact('entry'))); */ $this->create(); if (!$this->save($entry)) return array('error' => true, 'save_data' => $entry) + $ret; $ret['statement_entry_id'] = $this->id; return $ret + array('error' => false); } /************************************************************************** ************************************************************************** ************************************************************************** * function: reverse * - Reverses the charges * * SAMPLE MOVE IN w/ PRE PAYMENT * DEPOSIT RENT A/R RECEIPT CHECK PETTY BANK * ------- ------- ------- ------- ------- ------- ------- * |25 | 25| | | | | * | |20 20| | | | | * | |20 20| | | | | * | |20 20| | | | | * | | |25 25| | | | * | | |20 20| | | | * | | |20 20| | | | * | | |20 20| | | | * | | | |85 85| | | * | | | | |85 | 85| * MOVE OUT and REFUND FINAL MONTH * DEPOSIT RENT C/P RECEIPT CHECK PETTY BANK * ------- ------- ------- ------- ------- ------- ------- * 25| | |25 | | | | t20 e20a * | 20| |20 | | | | t20 e20b * -ONE REFUND CHECK- * | | 25| |25 | | | t30 e30a * | | 20| |20 | | | t30 e30b * | | | 45| | | |45 t40 e40 * -OR MULTIPLE- * | | 15| |15 | | | t50a e50a * | | | 15| | |15 | t60a e60a * | | 30| |30 | | | t50b e50b * | | | 30| | | |30 t60b e60b * | | | | | | | OPTION 1 * |-25 | -25| | | | | * | |-20 -20| | | | | * | | |-25 -25| | | | * | | |-20 -20| | | | OPTION 2 * |-25 | | -25| | | | * | |-20 | -20| | | | * | | | |-15 | -15| | * | | | |-30 | | -30| * | | | | | | | * */ function reverse($ledger_entries, $stamp = null) { pr(array('Entry::reverse', compact('ledger_entries', 'stamp'))); // If the user only wants to reverse one ID, we'll allow it if (!is_array($ledger_entries)) $ledger_entries = $this->find ('all', array ('contain' => false, 'conditions' => array('Entry.id' => $ledger_entries))); $A = new Account(); $ar_account_id = $A->accountReceivableAccountID(); $receipt_account_id = $A->receiptAccountID(); $transaction_id = null; foreach ($ledger_entries AS $entry) { $entry = $entry['Entry']; $amount = -1*$entry['amount']; if (isset($entry['credit_account_id'])) $refund_account_id = $entry['credit_account_id']; elseif (isset($entry['CreditLedger']['Account']['id'])) $refund_account_id = $entry['CreditLedger']['Account']['id']; elseif (isset($entry['credit_ledger_id'])) $refund_account_id = $this->Ledger->accountID($entry['credit_ledger_id']); else return null; // post new refund in the income account $ids = $A->postEntry (array('transaction_id' => $transaction_id), null, array('debit_ledger_id' => $A->currentLedgerID($ar_account_id), 'credit_ledger_id' => $A->currentLedgerID($refund_account_id), 'effective_date' => $entry['effective_date'], 'through_date' => $entry['through_date'], 'amount' => $amount, 'lease_id' => $entry['lease_id'], 'customer_id' => $entry['customer_id'], 'comment' => "Refund; Entry #{$entry['id']}", ), array('debit' => array (array('Entry' => array('id' => $entry['id'], 'amount' => $amount))), ) ); if ($ids['error']) return null; $transaction_id = $ids['transaction_id']; pr(array('checkpoint' => 'Posted Refund Ledger Entry', compact('ids', 'amount', 'refund_account_id', 'ar_account_id'))); } return true; } /************************************************************************** ************************************************************************** ************************************************************************** * function: reconciledSet * - Returns the set of entries satisfying the given conditions, * along with any entries that they reconcile */ function reconciledSetQuery($set, $query) { $this->queryInit($query); if ($set == 'CHARGE' || $set == 'PAYMENT') $query['conditions'][] = array('StatementEntry.type' => $set); else die("INVALID RECONCILE SET"); if ($set == 'CHARGE') $query['link']['PaymentEntry'] = array('fields' => array("SUM(PaymentEntry.amount) AS reconciled")); if ($set == 'PAYMENT') $query['link']['ChargeEntry'] = array('fields' => array("SUM(ChargeEntry.amount) AS reconciled")); $query['group'] = 'StatementEntry.id'; // REVISIT: TESTING //$query['link']['PaymentEntry'] = array('fields' => array("(`PaymentEntry.amount`+0) AS reconciled")); //$query['group'] = null; // END REVISIT return $query; } function reconciledSet($set, $query = null, $unrec = false) { $lquery = $this->reconciledSetQuery($set, $query); $result = $this->find('all', $lquery); //pr(array('StatementEntry::reconciledSet' => compact('set', 'unrec', 'lquery', 'result'))); $resultset = array(); foreach ($result AS $i => $entry) { //pr(compact('entry')); if (!empty($entry[0])) $entry['StatementEntry'] = $entry[0] + $entry['StatementEntry']; unset($entry[0]); $entry['StatementEntry']['balance'] = $entry['StatementEntry']['amount'] - $entry['StatementEntry']['reconciled']; // Since HAVING isn't a builtin feature of CakePHP, // we'll have to post-process to get the desired entries if ($entry['StatementEntry']['balance'] == 0) { if (!$unrec) $resultset[] = $entry; } else { if ($unrec) $resultset[] = $entry; } } //pr(array('StatementEntry::reconciledSet' => compact('resultset'))); //$resultset['stats'] = $this->stats(null, $query); //pr($this->stats(null, $query)); return array('entries' => $resultset, 'summary' => $this->stats(null, $query)); } /************************************************************************** ************************************************************************** ************************************************************************** * function: reconciledEntries * - Returns a list of entries that reconcile against the given entry. * (such as payments towards a charge). */ function reconciledEntriesQuery($id, $query = null) { $this->queryInit($query, false); $this->id = $id; $this->recursive = -1; $this->read(); $query['conditions'][] = array('StatementEntry.id' => $id); if ($this->data['StatementEntry']['type'] == 'CHARGE') $query['link']['PaymentEntry'] = array(); if ($this->data['StatementEntry']['type'] == 'PAYMENT') $query['link']['ChargeEntry'] = array(); return $query; } function reconciledEntries($id, $query = null) { $lquery = $this->reconciledEntriesQuery($id, $query); //pr(array('reconciledEntries', compact('entry', 'contain'))); $result = $this->find('all', $lquery); foreach (array_keys($result) AS $i) unset($result[$i]['StatementEntry']); //pr(array('StatementEntry::reconciledEntries()' => compact('result'))); return array('entries' => $result); //'summary' => $this->stats($id, $query)); } /************************************************************************** ************************************************************************** ************************************************************************** * function: assignCredits * - Assigns all credits to existing charges */ function assignCredits($query = null, $receipt_id = null) { /* pr(array('StatementEntry::assignCredits' => compact('query', 'receipt_id'))); */ $this->queryInit($query); $ret = array(); // First, find all known credits $lquery = $query; $lquery['conditions'][] = array('StatementEntry.type' => 'CREDIT'); $lquery['order'][] = 'StatementEntry.effective_date ASC'; $credits = $this->find('all', $lquery); /* pr(compact('lquery', 'credits')); */ // Then, find all receipts that have not had all // monies dispursed for either payments or credits // REVISIT : If we implement CREDITS as we're // anticipating, then this concept of "anonymous" // credits won't exists (i.e. credits that are // not explicitly specified with a statement entry // of type CREDIT). All transactions MUST balance // out to the sum of their statement entries, so // we'll be able to just delete all the anon_credit // code. $lquery = $query; $lquery['link'] = array('StatementEntry' => array('fields' => array()) + $lquery['link']); $lquery['conditions'][] = array('Transaction.type' => 'RECEIPT'); $lquery['fields'] = array('Transaction.id', 'Transaction.stamp', 'Transaction.amount', 'Transaction.amount - SUM(COALESCE(StatementEntry.amount,0)) AS balance'); $lquery['group'] = 'Transaction.id HAVING balance > 0'; $anon_credits = $this->Transaction->find('all', $lquery); foreach ($anon_credits AS &$ac) { $ac['Transaction'] += $ac[0]; unset($ac[0]); } /* pr(compact('lquery', 'anon_credits')); */ // Next, add in the credits from the newly added receipt if (!empty($receipt_id)) { $lquery = $query; $lquery['conditions'][] = array('Transaction.type' => 'RECEIPT'); $lquery['conditions'][] = array('Transaction.id' => $receipt_id); $lquery['fields'] = array('Transaction.id', 'Transaction.stamp', 'Transaction.amount', 'Transaction.amount AS balance'); $receipt_credit = $this->Transaction->find('first', $lquery); if ($receipt_credit) $anon_credits[] = $receipt_credit; /* pr(compact('lquery', 'anon_credits')); */ } // REVISIT : 20090726 // This algorithm shouldn't be hardcoded. We need to allow // the user to specify how payments should be applied. // Now find all unpaid charges $lquery = $query; $lquery['order'] = 'StatementEntry.effective_date ASC'; $charges = $this->reconciledSet('CHARGE', $query, true); /* pr(compact('lquery', 'charges')); */ // Initialize our list of used credits $used_credits = array(); $used_anon_credits = array(); // Work through all unpaid charges, applying payments as we go foreach ($charges['entries'] AS $charge) { /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'Process Charge') */ /* + compact('charge'))); */ // Check that we have available credits. // Technically, this isn't necessary, since the loop // will handle everything just fine. However, this // just saves extra processing if/when there is no // means to resolve a charge anyway. if (count($credits) == 0 && count($anon_credits) == 0) { /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'No available credits'))); */ break; } $charge['balance'] = $charge['StatementEntry']['balance']; while ($charge['balance'] > 0 && (count($credits) || count($anon_credits))) { /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'Attempt Charge Reconciliation') */ /* + compact('charge'))); */ // Use explicit credits before using implicit credits // (Not sure it matters though). if (count($credits)) { // Peel off the first credit available $credit =& $credits[0]; $payment_date = $credit['StatementEntry']['effective_date']; $payment_transaction_id = $credit['StatementEntry']['transaction_id']; if (!isset($credit['balance'])) $credit['balance'] = $credit['StatementEntry']['amount']; } elseif (count($anon_credits)) { // Peel off the first credit available $credit =& $anon_credits[0]; $payment_date = $credit['Transaction']['stamp']; $payment_transaction_id = $credit['Transaction']['id']; if (!isset($credit['balance'])) $credit['balance'] = $credit['Transaction']['balance']; } else { die("HOW DID WE GET HERE WITH NO CREDITS?"); } // Set the payment amount to the maximum amount // possible without exceeding the charge or credit balance $payment_amount = min($charge['balance'], $credit['balance']); if (!isset($credit['applied'])) $credit['applied'] = 0; $credit['applied'] += $payment_amount; $credit['balance'] -= $payment_amount; /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => (($credit['balance'] > 0 ? 'Utilized' : 'Exhausted') */ /* . (count($credits) ? '' : ' Anon') */ /* . ' Credit')) */ /* + compact('credit'))); */ if ($credit['balance'] < 0) die("HOW DID WE END UP WITH NEGATIVE CREDIT BALANCE?"); // If we've exhaused the credit, get it out of the // available credit pool (but keep track of it for later). if ($credit['balance'] <= 0) { if (count($credits)) $used_credits[] = array_shift($credits); else $used_anon_credits[] = array_shift($anon_credits); } // Add a payment that uses the available credit to pay the charge $payment = array('type' => 'PAYMENT', 'account_id' => $this->Account->accountReceivableAccountID(), 'amount' => $payment_amount, 'effective_date' => $payment_date, 'transaction_id' => $payment_transaction_id, 'customer_id' => $charge['StatementEntry']['customer_id'], 'lease_id' => $charge['StatementEntry']['lease_id'], 'charge_entry_id' => $charge['StatementEntry']['id'], 'comment' => null, ); /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'New Payment Entry') */ /* + compact('payment'))); */ $result = $this->addStatementEntry($payment); $ret['Payment'][] = $result; if ($result['error']) $ret['error'] = true; // Adjust the charge balance to reflect the new payment $charge['balance'] -= $payment_amount; if ($charge['balance'] < 0) die("HOW DID WE GET A NEGATIVE CHARGE AMOUNT?"); /* if ($charge['balance'] <= 0) */ /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'Fully Paid Charge'))); */ } } // Partially used credits must be added to the used list if (isset($credits[0]['applied'])) $used_credits[] = array_shift($credits); /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'Payments added') */ /* + compact('credits', 'used_credits', 'anon_credits', 'used_anon_credits'))); */ // Clean up any explicit credits that have been used foreach ($used_credits AS $credit) { if ($credit['balance'] > 0) { /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'Update Credit Entry') */ /* + compact('credit'))); */ $this->id = $credit['StatementEntry']['id']; $this->saveField('amount', $credit['balance']); } else { /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'Delete Exhausted Credit Entry') */ /* + compact('credit'))); */ $this->del($credit['StatementEntry']['id'], false); } } // Convert non-exhausted implicit credits to explicit ones foreach ($anon_credits AS $credit) { if ($credit['balance'] <= 0) die("HOW DID EXHAUSTED ANON CREDITS GET LEFT?"); /* pr(array('StatementEntry::assignCredits' => */ /* array('checkpoint' => 'Create Explicit Credit') */ /* + compact('credit'))); */ $result = $this->addStatementEntry (array('type' => 'CREDIT', 'account_id' => $this->Account->accountReceivableAccountID(), 'amount' => $credit['balance'], 'effective_date' => $credit['Transaction']['stamp'], 'transaction_id' => $credit['Transaction']['id'], 'customer_id' => $credit['Customer']['id'], )); $ret['Credit'][] = $result; if ($result['error']) $ret['error'] = true; } return $ret + array('error' => false); } /************************************************************************** ************************************************************************** ************************************************************************** * function: stats * - Returns summary data from the requested statement entry */ function stats($id = null, $query = null) { $this->queryInit($query); unset($query['group']); /* pr(array('StatementEntry::stats' => compact('id', 'query'))); */ $stats = array(); if (isset($id)) $query['conditions'][] = array('StatementEntry.id' => $id); $rquery = $query; unset($rquery['link']['ChargeEntry']); $rquery['link']['PaymentEntry'] = array('fields' => array()); $rquery['fields'] = array(); $rquery['fields'][] = "StatementEntry.amount"; $rquery['fields'][] = "SUM(PaymentEntry.amount) AS reconciled"; $rquery['conditions'][] = array('StatementEntry.type' => 'CHARGE'); $rquery['group'] = 'StatementEntry.id'; $result = $this->find('all', $rquery); $stats['Charge'] = array('total' => 0, 'reconciled' => 0); foreach($result AS $charge) { $stats['Charge']['total'] += $charge['StatementEntry']['amount']; $stats['Charge']['reconciled'] += $charge[0]['reconciled']; } $stats['Charge']['balance'] = $stats['Charge']['total'] - $stats['Charge']['reconciled']; /* pr(array('StatementEntry::stats' => */ /* array('checkpoint' => 'Charges') */ /* + compact('query', 'result'))); */ $rquery = $query; unset($rquery['link']['PaymentEntry']); $rquery['link']['ChargeEntry'] = array('fields' => array()); $rquery['fields'] = array(); $rquery['fields'][] = "SUM(StatementEntry.amount) AS total"; $rquery['fields'][] = "SUM(IF(ChargeEntry.id IS NULL, 0, StatementEntry.amount)) AS reconciled"; $rquery['fields'][] = "SUM(IF(ChargeEntry.id IS NULL, StatementEntry.amount, 0)) AS balance"; $rquery['conditions'][] = array('StatementEntry.type' => 'PAYMENT'); $result = $this->find('first', $rquery); if (!isset($result[0]['balance'])) $result[0]['balance'] = 0; $stats['Payment'] = $result[0]; /* pr(array('StatementEntry::stats' => */ /* array('checkpoint' => 'Payments') */ /* + compact('rquery', 'result'))); */ /* pr(array('StatementEntry::stats' => */ /* array('checkpoint' => 'return') */ /* + compact('stats'))); */ return $stats; } }