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', 'dependent' => true, ), ); var $default_log_level = array('log' => 30, 'show' => 15); var $max_log_level = 10; /************************************************************************** ************************************************************************** ************************************************************************** * function: debit/creditTypes */ function debitTypes() { return array('CHARGE', 'PAYMENT', 'REFUND'); } function creditTypes() { return array('DISBURSEMENT', 'WAIVER', 'REVERSAL', 'WRITEOFF', 'SURPLUS'); } function voidTypes() { return array('VOID'); } /************************************************************************** ************************************************************************** ************************************************************************** * function: chargeDisbursementFields */ function chargeDisbursementFields($sum = false, $entry_name = 'StatementEntry') { $debits = $this->debitTypes(); $credits = $this->creditTypes(); $voids = $this->voidTypes(); foreach ($debits AS &$enum) $enum = "'" . $enum . "'"; foreach ($credits AS &$enum) $enum = "'" . $enum . "'"; foreach ($voids AS &$enum) $enum = "'" . $enum . "'"; $debit_set = implode(", ", $debits); $credit_set = implode(", ", $credits); $void_set = implode(", ", $voids); $fields = array ( ($sum ? 'SUM(' : '') . "IF({$entry_name}.type IN ({$debit_set})," . " {$entry_name}.amount, NULL)" . ($sum ? ')' : '') . ' AS charge' . ($sum ? 's' : ''), ($sum ? 'SUM(' : '') . "IF({$entry_name}.type IN({$credit_set})," . " {$entry_name}.amount, NULL)" . ($sum ? ')' : '') . ' AS disbursement' . ($sum ? 's' : ''), ($sum ? 'SUM(' : '') . "IF({$entry_name}.type IN ({$debit_set}), 1," . " IF({$entry_name}.type IN ({$credit_set}), -1, 0))" . " * 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 $this->prReturn(array('error' => true, 'verify_data' => $entry) + $ret); $this->pr(20, array('checkpoint' => 'Pre-Save') + compact('entry')); $this->create(); if (!$this->save($entry)) return $this->prReturn(array('error' => true, 'save_data' => $entry) + $ret); $ret['statement_entry_id'] = $this->id; return $this->prReturn($ret + array('error' => false)); } /************************************************************************** ************************************************************************** ************************************************************************** * function: waive * - Waives the charge balance * */ 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']; if ($charge['type'] !== 'CHARGE') $this->INTERNAL_ERROR("Waiver item is not CHARGE."); // 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"; // Add the charge waiver $waiver['Entry'][] = array('amount' => $stats['Charge']['balance'], 'comment' => null, ); // Record the waiver transaction return $this->prReturn($this->Transaction->addWaiver ($waiver, $id, $charge['customer_id'], $charge['lease_id'])); } /************************************************************************** ************************************************************************** ************************************************************************** * function: reversable * - Returns true if the charge can be reversed; false otherwise */ function reversable($id) { $this->prEnter(compact('id')); // Verify the item is an actual charge $this->id = $id; $charge_type = $this->field('type'); if ($charge_type !== 'CHARGE') return $this->prReturn(false); // Determine anything reconciled against the charge $reverse_transaction_id = $this->field('reverse_transaction_id'); if (!empty($reverse_transaction_id)) return $this->prReturn(false); return $this->prReturn(true); } /************************************************************************** ************************************************************************** ************************************************************************** * function: reverse * - Reverses the charges */ function reverse($id, $stamp = null) { $this->prEnter(compact('id', 'stamp')); // Verify the item can be reversed if (!$this->reversable($id)) $this->INTERNAL_ERROR("Item is not reversable."); // Get the basic information about this charge $charge = $this->find('first', array('contain' => true)); //$charge = $charge['StatementEntry']; // Query the stats to get the remaining balance $stats = $this->stats($id); $charge['paid'] = $stats['Charge']['disbursement']; // Record the reversal transaction $result = $this->Transaction->addReversal ($charge, $stamp, 'Charge Reversal'); // Mark the charge as reversed $this->id = $id; $this->saveField('reverse_transaction_id', $result['transaction_id']); return $this->prReturn($result); } /************************************************************************** ************************************************************************** ************************************************************************** * 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 (in_array($set, $this->debitTypes())) $query['link']['DisbursementEntry'] = array('fields' => array("SUM(DisbursementEntry.amount) AS reconciled")); elseif (in_array($set, $this->creditTypes())) $query['link']['ChargeEntry'] = array('fields' => array("SUM(ChargeEntry.amount) AS reconciled")); else die("INVALID RECONCILE SET"); $query['conditions'][] = array('StatementEntry.type' => $set); $query['group'] = 'StatementEntry.id'; return $query; } function reconciledSet($set, $query = null, $unrec = false, $if_rec_include_partial = false) { $this->prFunctionLevel(array('log' => 16, 'show' => 10)); $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 (in_array($this->data['StatementEntry']['type'], $this->debitTypes())) { $query['link']['DisbursementEntry'] = array(); $query['conditions'][] = array('DisbursementEntry.id !=' => null); } if (in_array($this->data['StatementEntry']['type'], $this->creditTypes())) { $query['link']['ChargeEntry'] = array(); $query['conditions'][] = array('ChargeEntry.id !=' => null); } 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, unless this call is to make // credit adjustments to a specific charge if (empty($receipt_id)) { if (!empty($charge_ids)) $this->INTERNAL_ERROR("Charge IDs, yet no corresponding receipt"); $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"); } else { // Establish credit from the (newly added) receipt $lquery = array('link' => array('StatementEntry', 'LedgerEntry' => array('conditions' => array('LedgerEntry.account_id <> Transaction.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) $this->INTERNAL_ERROR("Unable to locate receipt."); $stats = $this->Transaction->stats($receipt_id); $receipt_credit['balance'] = $stats['undisbursed']; $receipt_credit['receipt'] = true; $credits = array($receipt_credit); $this->pr(18, compact('credits'), "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 = array(); foreach ($this->debitTypes() AS $dtype) { $rset = $this->reconciledSet($dtype, $lquery, true); $entries = $rset['entries']; $charges = array_merge($charges, $entries); $this->pr(18, compact('dtype', 'entries'), "Outstanding Debit Entries"); } // Work through all unpaid charges, applying disbursements as we go foreach ($charges AS $charge) { $this->pr(20, compact('charge'), 'Process Charge'); $charge['balance'] = $charge['StatementEntry']['balance']; // Use explicit credits before using the new receipt credit foreach ($credits AS &$credit) { if (empty($charge['balance'])) break; if ($charge['balance'] < 0) $this->INTERNAL_ERROR("Negative Charge Balance"); if (!isset($credit['balance'])) $credit['balance'] = $credit['StatementEntry']['amount']; if (empty($credit['balance'])) continue; if ($credit['balance'] < 0) $this->INTERNAL_ERROR("Negative Credit Balance"); $this->pr(20, compact('charge'), 'Attempt Charge Reconciliation'); if (empty($credit['receipt'])) $disbursement_account_id = $credit['StatementEntry']['account_id']; else $disbursement_account_id = $credit['LedgerEntry']['account_id']; // REVISIT : 20090811 // Need to come up with a better strategy for handling // concessions. For now, just restricting concessions // to apply only towards rent will resolve the most // predominant (or only) needed usage case. if ($disbursement_account_id == $this->Account->concessionAccountID() && $charge['StatementEntry']['account_id'] != $this->Account->rentAccountID()) continue; // 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') . (empty($credit['receipt']) ? ' Credit' : ' Receipt')); if (strtotime($charge['StatementEntry']['effective_date']) > strtotime($credit['StatementEntry']['effective_date'])) $disbursement_edate = $charge['StatementEntry']['effective_date']; else $disbursement_edate = $credit['StatementEntry']['effective_date']; if (empty($credit['receipt'])) { // Explicit Credit $result = $this->Transaction->addTransactionEntries (array('include_ledger_entry' => true, 'include_statement_entry' => true), array('type' => 'INVOICE', 'id' => $credit['StatementEntry']['transaction_id'], 'account_id' => $this->Account->accountReceivableAccountID(), 'crdr' => 'CREDIT', 'customer_id' => $charge['StatementEntry']['customer_id'], 'lease_id' => $charge['StatementEntry']['lease_id'], ), array (array('type' => $disbursement_type, 'effective_date' => $disbursement_edate, 'account_id' => $credit['StatementEntry']['account_id'], 'amount' => $disbursement_amount, 'charge_entry_id' => $charge['StatementEntry']['id'], ), )); $ret['Disbursement'][] = $result; if ($result['error']) $ret['error'] = true; } else { // Receipt Credit if (strtotime($charge['StatementEntry']['effective_date']) > strtotime($credit['Transaction']['stamp'])) $disbursement_edate = $charge['StatementEntry']['effective_date']; else $disbursement_edate = $credit['Transaction']['stamp']; // Add a disbursement that uses the available credit to pay the charge $disbursement = array('type' => $disbursement_type, 'effective_date' => $disbursement_edate, 'amount' => $disbursement_amount, 'account_id' => $credit['LedgerEntry']['account_id'], 'transaction_id' => $credit['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'); } // Break the $credit reference to avoid future problems unset($credit); } $this->pr(18, compact('credits'), 'Disbursements complete'); // Clean up any explicit credits that have been used foreach ($credits AS $credit) { if (!empty($credit['receipt'])) continue; if (empty($credit['applied'])) continue; 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->delete($credit['StatementEntry']['id'], false); } } // Check for any implicit receipt credits, converting // into explicit credits if there is a remaining balance. foreach ($credits AS $credit) { if (empty($credit['receipt'])) continue; if (empty($credit['balance'])) continue; // See if there is an existing explicit credit // for this transaction. $explicit_credit = $this->find ('first', array('contain' => false, 'conditions' => array(array('transaction_id' => $credit['Transaction']['id']), array('type' => 'SURPLUS')), )); if (!empty($explicit_credit)) { // REVISIT : 20090815 // Testing whether or not this case occurs $this->INTERNAL_ERROR('Existing explicit credit unexpected'); // Since there IS an existing explicit credit, we must update // its balance instead of creating a new one, since it has // already been incorporated in the overall credit balance. // If we were to create a new one, we would erroneously create // an excess of credit available. $this->pr(18, compact('explicit_credit', 'credit'), 'Update existing explicit credit'); $EC = new StatementEntry(); $EC->id = $explicit_credit['StatementEntry']['id']; $EC->saveField('amount', $credit['balance']); continue; } if (!empty($ret['receipt_balance'])) $this->INTERNAL_ERROR('Only one receipt expected in assignCredits'); // Give caller the information necessary to create an explicit // credit from the passed receipt, which we've not exhausted. $this->pr(18, compact('credit'), 'Convert to explicit credit'); $ret['receipt_balance'] = $credit['balance']; } return $this->prReturn($ret + array('error' => false)); } /************************************************************************** ************************************************************************** ************************************************************************** * function: stats * - Returns summary data from the requested statement entry */ function stats($id = null, $query = null) { $this->prFunctionLevel(array('log' => 16, 'show' => 10)); $this->prEnter(compact('id', 'query')); $this->queryInit($query); unset($query['group']); $stats = array(); if (isset($id)) $query['conditions'][] = array('StatementEntry.id' => $id); $types = array('Charge', 'Disbursement'); foreach ($types AS $type_index => $this_name) { $that_name = $types[($type_index + 1) % 2]; if ($this_name === 'Charge') { $this_types = $this->debitTypes(); $that_types = $this->creditTypes(); } else { $this_types = $this->creditTypes(); $that_types = $this->debitTypes(); } $this_query = $query; $this_query['fields'] = array(); $this_query['fields'][] = "SUM(StatementEntry.amount) AS total"; $this_query['conditions'][] = array('StatementEntry.type' => $this_types); $result = $this->find('first', $this_query); $stats[$this_name] = $result[0]; $this->pr(17, compact('this_query', 'result'), $this_name.'s'); // Tally the different types that result in credits towards the charges $stats[$this_name]['reconciled'] = 0; foreach ($that_types AS $that_type) { $lc_that_type = strtolower($that_type); $that_query = $this_query; $that_query['link']["{$that_name}Entry"] = array('fields' => array()); $that_query['fields'] = array(); if ($this_name == 'Charge') $that_query['fields'][] = "COALESCE(SUM(${that_name}Entry.amount),0) AS $lc_that_type"; else $that_query['fields'][] = "COALESCE(SUM(StatementEntry.amount), 0) AS $lc_that_type"; $that_query['conditions'][] = array("{$that_name}Entry.type" => $that_type); $result = $this->find('first', $that_query); $stats[$this_name] += $result[0]; $this->pr(17, compact('that_query', 'result'), "{$this_name}s: $that_type"); $stats[$this_name]['reconciled'] += $stats[$this_name][$lc_that_type]; } // Compute balance information for charges $stats[$this_name]['balance'] = $stats[$this_name]['total'] - $stats[$this_name]['reconciled']; if (!isset($stats[$this_name]['balance'])) $stats[$this_name]['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']['reconciled'] - $stats['Disbursement']['total']; if (!isset($stats['account_balance'])) $stats['account_balance'] = 0; return $this->prReturn($stats); } }