array( 'className' => 'StatementEntry', ), ); var $hasMany = array( // The disbursements that apply to this charge (if it is one) 'DisbursementEntry' => array( 'className' => 'StatementEntry', 'foreignKey' => 'charge_entry_id', ), ); //var $default_log_level = array('log' => 30, 'show' => 15); /************************************************************************** ************************************************************************** ************************************************************************** * function: debit/creditTypes */ function debitTypes() { return array('CHARGE', 'VOUCHER'); } function creditTypes() { return array('DISBURSEMENT', 'WAIVER', 'SURPLUS'); } /************************************************************************** ************************************************************************** ************************************************************************** * function: chargeDisbursementFields */ function chargeDisbursementFields($sum = false, $entry_name = 'StatementEntry') { $charges = array('CHARGE', 'VOUCHER'); $nulls = array('PAYMENT', 'VOID'); foreach ($charges AS &$enum) $enum = "'" . $enum . "'"; foreach ($nulls AS &$enum) $enum = "'" . $enum . "'"; $charge_set = implode(", ", $charges); $null_set = implode(", ", $nulls); $fields = array ( ($sum ? 'SUM(' : '') . "IF({$entry_name}.type IN ({$charge_set})," . " {$entry_name}.amount, NULL)" . ($sum ? ')' : '') . ' AS charge' . ($sum ? 's' : ''), ($sum ? 'SUM(' : '') . "IF({$entry_name}.type NOT IN({$charge_set}, ${null_set})," . " {$entry_name}.amount, NULL)" . ($sum ? ')' : '') . ' AS disbursement' . ($sum ? 's' : ''), ($sum ? 'SUM(' : '') . "IF({$entry_name}.type IN ({$null_set}), 0," . " IF({$entry_name}.type IN ({$charge_set}), 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) { $this->prFunctionLevel(10); $this->prEnter(compact('entry')); if (empty($entry['type']) || //empty($entry['effective_date']) || empty($entry['account_id']) || empty($entry['amount']) ) { return $this->prReturn(false); } return $this->prReturn(true); } /************************************************************************** ************************************************************************** ************************************************************************** * function: addStatementEntry * - Inserts new Statement Entry into the database */ function addStatementEntry($entry) { $this->prEnter(compact('entry')); $ret = array(); if (!$this->verifyStatementEntry($entry)) return array('error' => true, 'verify_data' => $entry) + $ret; $this->pr(20, 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 $this->prReturn($ret + array('error' => false)); } /************************************************************************** ************************************************************************** ************************************************************************** * function: waive * - Waives the charges * */ function waive($id, $stamp = null) { $this->prEnter(compact('id', 'stamp')); // Get the basic information about the entry to be waived. $this->recursive = -1; $charge = $this->read(null, $id); $charge = $charge['StatementEntry']; // Query the stats to get the remaining balance $stats = $this->stats($id); // Build a transaction $waiver = array('Transaction' => array(), 'Entry' => array()); $waiver['Transaction']['stamp'] = $stamp; $waiver['Transaction']['comment'] = "Charge Waiver"; if ($charge['type'] !== 'CHARGE') die("INTERNAL ERROR: WAIVER ITEM IS NOT CHARGE"); // Add the charge waiver $waiver['Entry'][] = array('amount' => $stats['Charge']['balance'], 'account_id' => $this->Account->waiverAccountID(), 'comment' => null, ); // Record the waiver transaction return $this->prReturn($this->Transaction->addWaiver ($waiver, $id, $charge['customer_id'], $charge['lease_id'])); } /************************************************************************** ************************************************************************** ************************************************************************** * function: reverse * - Reverses the charges * */ function reverse($id, $stamp = null) { $this->prEnter(compact('id', 'stamp')); $ret = array(); // Get the basic information about the entry to be reversed. $this->recursive = -1; $charge = $this->read(null, $id); $charge = $charge['StatementEntry']; $voided_entry_transactions = array(); $reconciled = $this->reconciledEntries($id); $this->pr(21, compact('reconciled')); if ($reconciled) { foreach ($reconciled['entries'] AS $entry) { $voided_entry_transactions[$entry['DisbursementEntry']['transaction_id']] = array_intersect_key($entry['DisbursementEntry'], array('customer_id'=>1, 'lease_id'=>1)); $this->del($entry['DisbursementEntry']['id']); continue; $DE = new StatementEntry(); $DE->id = $entry['DisbursementEntry']['id']; $DE->saveField('type', 'VOID'); $DE->saveField('charge_entry_id', null); } $this->pr(17, compact('voided_entry_transactions')); } // Query the stats to get the remaining balance $stats = $this->stats($id); // Build a transaction $reversal = array('Transaction' => array(), 'Entry' => array()); $reversal['Transaction']['stamp'] = $stamp; $reversal['Transaction']['comment'] = "Credit Note: Charge Reversal"; if ($charge['type'] !== 'CHARGE') die("INTERNAL ERROR: REVERSAL ITEM IS NOT CHARGE"); // Add the charge reversal $reversal['Entry'][] = array('amount' => $stats['Charge']['total'], 'account_id' => $charge['account_id'], 'comment' => 'Charge Reversal', ); // Record the reversal transaction $result = $this->Transaction->addReversal ($reversal, $id, $charge['customer_id'], $charge['lease_id']); $this->pr(21, compact('result')); $ret['reversal'] = $result; if ($result['error']) $ret['error'] = true; foreach ($voided_entry_transactions AS $transaction_id => $tx) { $result = $this->assignCredits (null, $transaction_id, null, null, $tx['customer_id'], $tx['lease_id'] ); $this->pr(21, compact('result')); $ret['assigned'][] = $result; if ($result['error']) $ret['error'] = true; } return $this->prReturn($ret + array('error' => false)); } /************************************************************************** ************************************************************************** ************************************************************************** * 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 == 'DISBURSEMENT') $query['conditions'][] = array('StatementEntry.type' => $set); else die("INVALID RECONCILE SET"); if ($set == 'CHARGE') $query['link']['DisbursementEntry'] = array('fields' => array("SUM(DisbursementEntry.amount) AS reconciled")); if ($set == 'DISBURSEMENT') $query['link']['ChargeEntry'] = array('fields' => array("SUM(ChargeEntry.amount) AS reconciled")); $query['group'] = 'StatementEntry.id'; // REVISIT: TESTING //$query['link']['DisbursementEntry'] = array('fields' => array("(`DisbursementEntry.amount`+0) AS reconciled")); //$query['group'] = null; // END REVISIT return $query; } function reconciledSet($set, $query = null, $unrec = false, $if_rec_include_partial = false) { //$this->prFunctionLevel(16); $this->prEnter(compact('set', 'query', 'unrec', 'if_rec_include_partial')); $lquery = $this->reconciledSetQuery($set, $query); $result = $this->find('all', $lquery); $this->pr(20, compact('lquery', 'result')); $resultset = array(); foreach ($result AS $i => $entry) { $this->pr(25, 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) $reconciled = true; elseif ($entry['StatementEntry']['reconciled'] == 0) $reconciled = false; else // Partial disbursement; depends on unrec $reconciled = (!$unrec && $if_rec_include_partial); // Add to the set, if it's been requested if ($reconciled == !$unrec) $resultset[] = $entry; } return $this->prReturn(array('entries' => $resultset, 'summary' => $this->stats(null, $query))); } /************************************************************************** ************************************************************************** ************************************************************************** * function: reconciledEntries * - Returns a list of entries that reconcile against the given entry. * (such as disbursements 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']['DisbursementEntry'] = array(); if ($this->data['StatementEntry']['type'] == 'DISBURSEMENT') $query['link']['ChargeEntry'] = array(); return $query; } function reconciledEntries($id, $query = null) { $this->prEnter(compact('id', 'query')); $lquery = $this->reconciledEntriesQuery($id, $query); $result = $this->find('all', $lquery); foreach (array_keys($result) AS $i) unset($result[$i]['StatementEntry']); return $this->prReturn(array('entries' => $result)); } /************************************************************************** ************************************************************************** ************************************************************************** * function: assignCredits * - Assigns all credits to existing charges * * REVISIT : 20090726 * This algorithm shouldn't be hardcoded. We need to allow * the user to specify how disbursements should be applied. * */ function assignCredits($query = null, $receipt_id = null, $charge_ids = null, $disbursement_type = null, $customer_id = null, $lease_id = null) { $this->prFunctionLevel(25); $this->prEnter(compact('query', 'receipt_id', 'charge_ids', 'disbursement_type', 'customer_id', 'lease_id')); $this->queryInit($query); if (!empty($customer_id)) $query['conditions'][] = array('StatementEntry.customer_id' => $customer_id); if (empty($disbursement_type)) $disbursement_type = 'DISBURSEMENT'; $ret = array(); // First, find all known credits $lquery = $query; $lquery['conditions'][] = array('StatementEntry.type' => 'SURPLUS'); // REVISIT : 20090804 // We need to ensure that we're using surplus credits ONLY from either // the given lease, or those that do not apply to any specific lease. // However, by doing this, it forces any lease surplus amounts to // remain frozen with that lease until either there is a lease charge, // we refund the money, or we "promote" that surplus to the customer // level and out of the leases direct control. // That seems like a pain. Perhaps we should allow any customer // surplus to be used on any customer charge. $lquery['conditions'][] = array('OR' => array(array('StatementEntry.lease_id' => null), (!empty($lease_id) ? array('StatementEntry.lease_id' => $lease_id) : array()), )); $lquery['order'][] = 'StatementEntry.effective_date ASC'; $credits = $this->find('all', $lquery); $this->pr(18, compact('credits'), "Credits Established"); // Next, establish credit from the newly added receipt $receipt_credit = null; if (!empty($receipt_id)) { $lquery = array('link' => array('StatementEntry', 'LedgerEntry' => array('conditions' => array('LedgerEntry.account_id !=' => $this->Account->accountReceivableAccountID()), ), ), 'conditions' => array('Transaction.id' => $receipt_id), 'fields' => array('Transaction.id', 'Transaction.stamp', 'Transaction.amount'), ); $receipt_credit = $this->Transaction->find('first', $lquery); if (!$receipt_credit) die("INTERNAL ERROR: UNABLE TO LOCATE RECEIPT"); //$reconciled = $this->reconciledEntries($id); $stats = $this->Transaction->stats($receipt_id); $receipt_credit['balance'] = $receipt_credit['Transaction']['amount'] - $stats['Disbursement']['total']; $this->pr(18, compact('receipt_credit'), "Receipt Credit Added"); } // Now find all unpaid charges if (isset($charge_ids)) { $lquery = array('contain' => false, 'conditions' => array('StatementEntry.id' => $charge_ids)); } else { $lquery = $query; // If we're working with a specific lease, then limit charges to it if (!empty($lease_id)) $lquery['conditions'][] = array('StatementEntry.lease_id' => $lease_id); } $lquery['order'] = 'StatementEntry.effective_date ASC'; $charges = $this->reconciledSet('CHARGE', $lquery, true); $this->pr(18, compact('charges'), "Outstanding Charges Determined"); // Initialize our list of used credits $used_credits = array(); // Work through all unpaid charges, applying disbursements as we go foreach ($charges['entries'] AS $charge) { $this->pr(20, compact('charge'), 'Process 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 && empty($receipt_credit['balance'])) { $this->pr(17, 'No more available credits'); break; } $charge['balance'] = $charge['StatementEntry']['balance']; while ($charge['balance'] > 0 && (count($credits) || !empty($receipt_credit['balance']))) { $this->pr(20, compact('charge'), 'Attempt Charge Reconciliation'); // Use explicit credits before using implicit credits // (Not sure it matters though). if (count($credits)) { // Peel off the first credit available $credit =& $credits[0]; $disbursement_date = $credit['StatementEntry']['effective_date']; $disbursement_transaction_id = $credit['StatementEntry']['transaction_id']; $disbursement_account_id = $credit['StatementEntry']['account_id']; if (!isset($credit['balance'])) $credit['balance'] = $credit['StatementEntry']['amount']; } elseif (!empty($receipt_credit['balance'])) { // Use our only receipt credit $credit =& $receipt_credit; $disbursement_date = $credit['Transaction']['stamp']; $disbursement_transaction_id = $credit['Transaction']['id']; $disbursement_account_id = $credit['LedgerEntry']['account_id']; } else { die("HOW DID WE GET HERE WITH NO SURPLUS?"); } // Set the disbursement amount to the maximum amount // possible without exceeding the charge or credit balance $disbursement_amount = min($charge['balance'], $credit['balance']); if (!isset($credit['applied'])) $credit['applied'] = 0; $credit['applied'] += $disbursement_amount; $credit['balance'] -= $disbursement_amount; $this->pr(20, compact('credit'), ($credit['balance'] > 0 ? 'Utilized' : 'Exhausted') . (count($credits) ? ' Credit' : ' Receipt')); if ($credit['balance'] < 0) die("HOW DID WE END UP WITH NEGATIVE SURPLUS BALANCE?"); // If we've exhausted the credit, get it out of the // available credit pool (but keep track of it for later). if ($credit['balance'] <= 0 && count($credits)) $used_credits[] = array_shift($credits); // Add a disbursement that uses the available credit to pay the charge $disbursement = array('type' => $disbursement_type, 'account_id' => $disbursement_account_id, 'amount' => $disbursement_amount, 'effective_date' => $disbursement_date, 'transaction_id' => $disbursement_transaction_id, 'customer_id' => $charge['StatementEntry']['customer_id'], 'lease_id' => $charge['StatementEntry']['lease_id'], 'charge_entry_id' => $charge['StatementEntry']['id'], 'comment' => null, ); $this->pr(20, compact('disbursement'), 'New Disbursement Entry'); $result = $this->addStatementEntry($disbursement); $ret['Disbursement'][] = $result; if ($result['error']) $ret['error'] = true; // Adjust the charge balance to reflect the new disbursement $charge['balance'] -= $disbursement_amount; if ($charge['balance'] < 0) die("HOW DID WE GET A NEGATIVE CHARGE AMOUNT?"); if ($charge['balance'] <= 0) $this->pr(20, 'Fully Paid Charge'); } } // Partially used credits must be added to the used list if (isset($credits[0]['applied'])) $used_credits[] = array_shift($credits); $this->pr(18, compact('credits', 'used_credits', 'receipt_credit'), 'Disbursements added'); // Clean up any explicit credits that have been used foreach ($used_credits AS $credit) { if ($credit['balance'] > 0) { $this->pr(20, compact('credit'), 'Update Credit Entry'); $this->id = $credit['StatementEntry']['id']; $this->saveField('amount', $credit['balance']); } else { $this->pr(20, compact('credit'), 'Delete Exhausted Credit Entry'); $this->del($credit['StatementEntry']['id'], false); } } // Convert non-exhausted receipt credit to an explicit one if (!empty($receipt_credit['balance'])) { $credit =& $receipt_credit; $this->pr(18, compact('credit'), 'Create Explicit Credit'); $result = $this->addStatementEntry (array('type' => 'SURPLUS', 'account_id' => $credit['LedgerEntry']['account_id'], 'amount' => $credit['balance'], 'effective_date' => $credit['Transaction']['stamp'], 'transaction_id' => $credit['Transaction']['id'], 'customer_id' => $customer_id, 'lease_id' => $lease_id, )); $ret['Credit'] = $result; if ($result['error']) $ret['error'] = true; } return $this->prReturn($ret + array('error' => false)); } /************************************************************************** ************************************************************************** ************************************************************************** * function: stats * - Returns summary data from the requested statement entry */ function stats($id = null, $query = null) { $this->prEnter(compact('id', 'query')); $this->queryInit($query); unset($query['group']); $stats = array(); if (isset($id)) $query['conditions'][] = array('StatementEntry.id' => $id); // Determine the total in charges $charge_query = $query; unset($charge_query['link']['ChargeEntry']); unset($charge_query['link']['DisbursementEntry']); $charge_query['fields'] = array(); $charge_query['fields'][] = "SUM(StatementEntry.amount) AS total"; $charge_query['conditions'][] = array('StatementEntry.type' => array('CHARGE', 'VOUCHER')); $result = $this->find('first', $charge_query); $stats['Charge'] = $result[0]; $this->pr(17, compact('charge_query', 'result'), 'Charges'); // Tally the amount actually _paid_ to those charges $charge_disbursement_query = $charge_query; $charge_disbursement_query['link']['DisbursementEntry'] = array('fields' => array()); $charge_disbursement_query['fields'] = array(); $charge_disbursement_query['fields'][] = "COALESCE(SUM(DisbursementEntry.amount),0) AS paid"; $charge_disbursement_query['conditions'][] = array('DisbursementEntry.type' => 'DISBURSEMENT'); $result = $this->find('first', $charge_disbursement_query); $stats['Charge'] += $result[0]; $this->pr(17, compact('charge_disbursement_query', 'result'), 'Charge Disbursements'); // Tally the amount of charges that have been waived $charge_waiver_query = $charge_query; $charge_waiver_query['link']['DisbursementEntry'] = array('fields' => array()); $charge_waiver_query['fields'] = array(); $charge_waiver_query['fields'][] = "COALESCE(SUM(DisbursementEntry.amount),0) AS waived"; $charge_waiver_query['conditions'][] = array('DisbursementEntry.type' => 'WAIVER'); $result = $this->find('first', $charge_waiver_query); $stats['Charge'] += $result[0]; $this->pr(17, compact('charge_waiver_query', 'result'), 'Charge Waivers'); // Compute some summary information for charges $stats['Charge']['reconciled'] = $stats['Charge']['paid'] + $stats['Charge']['waived']; $stats['Charge']['balance'] = $stats['Charge']['total'] - $stats['Charge']['reconciled']; if (!isset($stats['Charge']['balance'])) $stats['Charge']['balance'] = 0; // Determine the total in disbursements, including those which // are charge waivers and those that do not even reconcile // to charges (i.e. they are surplus disbursements). $disbursement_query = $query; unset($disbursement_query['link']['DisbursementEntry']); $disbursement_query['link']['ChargeEntry'] = array('fields' => array()); $disbursement_query['fields'] = array(); $disbursement_query['fields'][] = "SUM(StatementEntry.amount) AS total"; $disbursement_query['fields'][] = "COALESCE(SUM(IF(ChargeEntry.id IS NULL, 0, StatementEntry.amount)), 0) AS charged"; $disbursement_query['fields'][] = "COALESCE(SUM(IF(ChargeEntry.id IS NULL, StatementEntry.amount, 0)), 0) AS surplus"; $disbursement_query['conditions'][] = array('StatementEntry.type' => array('DISBURSEMENT', 'WAIVER', 'SURPLUS')); $result = $this->find('first', $disbursement_query); $stats['Disbursement'] = $result[0]; $this->pr(17, compact('disbursement_query', 'result'), 'Disbursements'); // Compute some summary information for disbursements. // Add a reconciled field just for consistency with Charge. $stats['Disbursement']['reconciled'] = $stats['Disbursement']['charged']; $stats['Disbursement']['balance'] = $stats['Disbursement']['total'] - $stats['Disbursement']['reconciled']; if (!isset($stats['Disbursement']['balance'])) $stats['Disbursement']['balance'] = 0; // 'balance' is simply the difference between // the balances of charges and disbursements $stats['balance'] = $stats['Charge']['balance'] - $stats['Disbursement']['balance']; if (!isset($stats['balance'])) $stats['balance'] = 0; // 'account_balance' is really only relevant to // callers that have requested charge and disbursement // stats with respect to a particular account. // It represents the difference between inflow // and outflow from that account. $stats['account_balance'] = $stats['Charge']['paid'] - $stats['Disbursement']['total']; if (!isset($stats['account_balance'])) $stats['account_balance'] = 0; return $this->prReturn($stats); } }