This file contains all functions used by more than one file from the ccsg_accounting feature. * This feature is a simple accounting/bookkeeping function.

* * @package accounting * @author Joern Ott * @version 1.2 * @date 2010-11-16 * @copyright LGPL */ class AccountingLib extends LogsLib { /** * * Storing the book data if already requested once, this may save us a few queries * @var array $_book array with the books structure */ private $_book = ''; /** * Lists all books available to a user * @param string $order sorting order * @return array list of books (complete table structure) */ public function listBooks($order = 'bookId ASC') { $query = "SELECT * FROM tiki_acct_book ORDER BY $order"; return $this->fetchAll($query, []); } /** * * Creates a new book and gives full permissions to the creator * @param string $bookName descriptive name of the book * @param string $bookStartDate first permitted date for the book * @param string $bookEndDate last permitted date for the book * @param string $bookCurrency up to 3 letter cuurency code * @param int $bookCurrencyPos where should the currency symbol appear -1=before, 1=after * @param int $bookDecimals number of decimal points * @param string $bookDecPoint separator for the decimal point * @param string $bookThousand separator for the thousands * @param string $exportSeparator separator between fields when exporting CSV * @param string $exportEOL end of line definition, either CR, LF or CRLF * @param string $exportQuote Quote character to enclose strings in CSV * @param string $bookClosed 'y' if the book is closed (no more changes), 'n' otherwise * @param string $bookAutoTax * @return int/string bookId on success, error message otherwise */ public function createBook($bookName, $bookClosed, $bookStartDate, $bookEndDate, $bookCurrency, $bookCurrencyPos, $bookDecimals, $bookDecPoint, $bookThousand, $exportSeparator, $exportEOL, $exportQuote, $bookAutoTax = 'y') { global $user; $userlib = TikiLib::lib('user'); if (strlen($bookName) == 0) { return "The book must have a name"; } if (strtotime($bookStartDate) === false) { return "Invalid start date"; } if (strtotime($bookEndDate) === false) { return "Invalid end date"; } $query = "INSERT INTO `tiki_acct_book`" . " (`bookName`, `bookClosed`, `bookStartDate`, `bookEndDate`," . " `bookCurrency`, `bookCurrencyPos`, `bookDecimals`, `bookDecPoint`, `bookThousand`," . " `exportSeparator`, `exportEOL`, `exportQuote`, `bookAutoTax`)" . " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; $res = $this->query( $query, [ $bookName, $bookClosed, $bookStartDate, $bookEndDate, $bookCurrency, $bookCurrencyPos, $bookDecimals, $bookDecPoint, $bookThousand, $exportSeparator, $exportEOL, $exportQuote, $bookAutoTax ] ); $bookId = $this->lastInsertId(); $this->createTax($bookId, tra('No automated tax'), 0, 'n'); $groupId = $bookId; do { //make sure we don't have that group already $groupname = "accounting_book_$groupId"; $groupexists = $userlib->group_exists($groupname); if ($groupexists) { $groupId++; } } while ($groupexists); if ($groupId != $bookId) { $query = "UPDATE `tiki_acct_book` SET `bookId`=? WHERE `bookId`=?"; $res = $this->query($query, [$groupId, $bookId]); $bookId = $groupId; } $userlib->add_group($groupname); $userlib->assign_user_to_group($user, $groupname); $userlib->assign_object_permission($groupname, $bookId, 'accounting book', 'tiki_p_acct_view'); $userlib->assign_object_permission($groupname, $bookId, 'accounting book', 'tiki_p_acct_book'); $userlib->assign_object_permission($groupname, $bookId, 'accounting book', 'tiki_p_acct_manage_accounts'); $userlib->assign_object_permission($groupname, $bookId, 'accounting book', 'tiki_p_acct_book_stack'); $userlib->assign_object_permission($groupname, $bookId, 'accounting book', 'tiki_p_acct_book_import'); $userlib->assign_object_permission($groupname, $bookId, 'accounting book', 'tiki_p_acct_manage_template'); return $bookId; } /** * * Returns the details for a book with a given bookId * @param int $bookId Id of the book to retrieve the data for * @return array Array with book details */ public function getBook($bookId) { if (! is_array($this->_book) or $this->_book['bookId'] != $bookId) { $query = "SELECT * FROM `tiki_acct_book` WHERE `bookId`=?"; $res = $this->query($query, [$bookId]); $this->_book = $res->fetchRow(); } return $this->_book; } /** * * This function sets a books status to closed, so transactions can no longer be used * @param int $bookId id of the book to close * @return bool true on success */ public function closeBook($bookId) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { return false; } $query = "UPDATE `tiki_acct_book` SET `bookClosed`='y' WHERE `bookId`=?"; $res = $this->query($query, [$bookId]); if ($res === false) { return false; } return true; } /** * Returns the complete journal for a given account, if none is provided, the whole journal will be fetched * * @param int $bookId id of the current book * @param int $accountId account for which we should display the journal, defaults to '%' (all accounts) * @param string $order sorting order * @param int $limit max number of records to fetch, defaults to 0 = all * @return array|bool journal with all posts, false on errors */ public function getJournal($bookId, $accountId = '%', $order = '`journalId` ASC', $limit = 0) { $journal = []; if ($limit != 0) { if ($limit < 0) { $order = str_replace("ASC", "DESC", $order); } $order .= " LIMIT " . abs($limit); } if ($accountId == '%') { $query = "SELECT `journalId`, `journalDate`, `journalDescription`, `journalCancelled`" . " FROM `tiki_acct_journal`" . " WHERE `journalBookId`=?" . " ORDER BY $order"; $res = $this->query($query, [$bookId]); } else { $query = "SELECT `journalId`, `journalDate`, `journalDescription`, `journalCancelled`" . " FROM `tiki_acct_journal` INNER JOIN `tiki_acct_item`" . " ON (`tiki_acct_journal`.`journalBookId`=`tiki_acct_item`.`itemBookId` AND" . " `tiki_acct_journal`.`journalId`=`tiki_acct_item`.`itemJournalId`)" . " WHERE `journalBookId`=? AND `itemAccountId` LIKE ?" . " GROUP BY `journalId`, `journalDate`, `journalDescription`, `journalCancelled`" . " ORDER BY $order"; $res = $this->query($query, [$bookId, $accountId]); } if ($res === false) { return false; } while ($row = $res->fetchRow()) { $query = "SELECT * FROM `tiki_acct_item` WHERE `itemBookId`=? AND `itemJournalId`=? AND `itemType`=? ORDER BY `itemAccountId` ASC"; $row['debit'] = $this->fetchAll($query, [$bookId, $row['journalId'], -1]); $row['debitcount'] = count($row['debit']); $row['credit'] = $this->fetchAll($query, [$bookId, $row['journalId'], 1]); $row['creditcount'] = count($row['credit']); $row['maxcount'] = max($row['creditcount'], $row['debitcount']); $journal[] = $row; } return $journal; } /** * Returns the totals for a given book and account * * @param int $bookId id of the current book * @param int $accountId account for which we should fetch the totals, defaults to '%' (all accounts) * @return array array with three elements debit, credit and the total (credit-debit) */ public function getJournalTotals($bookId, $accountId = '%') { $journal = []; $query = "SELECT `itemAccountId`, SUM(`itemAmount`*IF(`itemType`<0,1,0)) AS debit," . " sum(`itemAmount`*IF(`itemType`>0,1,0)) AS credit" . " FROM `tiki_acct_journal` INNER JOIN `tiki_acct_item`" . " ON (`tiki_acct_journal`.`journalBookId`=`tiki_acct_item`.`itemBookId`" . " AND `tiki_acct_journal`.`journalId`=`tiki_acct_item`.`itemJournalId`)" . " WHERE `journalBookId`=? AND `itemAccountId` LIKE ?" . " GROUP BY `itemAccountId`"; $res = $this->query($query, [$bookId, $accountId]); $totals = $res->fetchRow(); $totals['total'] = $totals['credit'] - $totals['debit']; return $totals; } /** * Returns a list of accounts as defined in table tiki_acct_account * * @param int $bookId id of the book to retrieve the accounts for * @param string $order order of items, defaults to accountId * @param boolean $all true = fetch all accounts, false = fetch only unlocked accounts * @return array list of accounts */ public function getAccounts($bookId, $order = "`accountId` ASC", $all = false) { $query = 'SELECT * FROM `tiki_acct_account` WHERE `accountBookId`=? ' . ($all ? '' : 'AND `accountLocked`=0 ') . " ORDER BY $order"; return $this->fetchAll($query, [$bookId]); } /** * Returns an extended list of accounts with totals * * @param int $bookId id of the book to fetch the account list for * @param bool $all true = fetch all accounts or false = only unlocked accounts, defaults to false * @return array list of accounts */ public function getExtendedAccounts($bookId, $all = false) { $allcond = $all ? '' : ' AND accountLocked=0'; $query = "SELECT accountBookId, accountId, accountName, accountNotes, accountBudget, accountLocked, " . " SUM(itemAmount*IF(itemType<0,1,0)) AS debit, SUM(itemAmount*IF(itemType>0,1,0)) AS credit" . " FROM tiki_acct_account" . " LEFT JOIN tiki_acct_journal ON tiki_acct_account.accountBookId=tiki_acct_journal.journalBookId" . " LEFT JOIN tiki_acct_item ON tiki_acct_journal.journalId=tiki_acct_item.itemJournalId" . " AND tiki_acct_account.accountId=tiki_acct_item.itemAccountId" . " WHERE tiki_acct_account.accountBookId=? $allcond" . " GROUP BY accountId, accountName, accountNotes, accountBudget, accountLocked, accountBookId"; return $this->fetchAll($query, [$bookId]); } /** * Returns an array with all data from the account * * @param int $bookId id of the current book * @param int $accountId account id to retrieve * @param boolean $checkChangeable perform check, if the account is changeable * @return array account data or false on error */ public function getAccount($bookId, $accountId, $checkChangeable = true) { $query = "SELECT * FROM `tiki_acct_account` WHERE `accountbookId`=? AND `accountId`=?"; $res = $this->query($query, [$bookId, $accountId]); $account = $res->fetchRow(); if ($checkChangeable) { $account['changeable'] = $this->accountChangeable($bookId, $accountId); } return $account; } /** * Checks if this accountId can be changed or the account can be deleted. * This can only be done, if the account has not been used -> no posts exist for the account * * @param int $bookId id of the current book * @param int $accountId account id to check * @return boolean true, if the account can be changed/deleted */ public function accountChangeable($bookId, $accountId) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { return false; } $query = "SELECT Count(`itemAccountId`) AS posts" . " FROM `tiki_acct_journal`" . " INNER JOIN `tiki_acct_item` ON `tiki_acct_journal`.`journalId`=`tiki_acct_item`.`itemJournalId`" . " WHERE `journalBookId`=? and `itemAccountId`=?"; $res = $this->query($query, [$bookId, $accountId]); $posts = $res->fetchRow(); return ($posts['posts'] == 0); } /** * Creates an account with the given information * * @param int $bookId id of the current book * @param int $accountId id of the account to create * @param string $accountName name of the account to create * @param string $accountNotes notes for this account * @param float $accountBudget planned budget for the account * @param boolean $accountLocked can this account be used, 0=unlocked, 1=locked * @param int $accountTax taxId for tax automation * @return array|bool list of errors or true on success */ public function createAccount( $bookId, $accountId, $accountName, $accountNotes, $accountBudget, $accountLocked, $accountTax = 0 ) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { $errors = [tra("This book has been closed. You can't create new accounts.")]; return $errors; } $errors = $this->validateId('accountId', $accountId, 'tiki_acct_account', false, 'accountBookId', $bookId); if ($accountName == '') { $errors[] = tra('Account name must not be empty.'); } $cleanbudget = $this->cleanupAmount($bookId, $accountBudget); if ($cleanbudget === '') { $errors[] = tra('Budget is not a valid amount: ') . $accountBudget; } if ($accountLocked != 0 and $accountLocked != 1) { $errors[] = tra('Locked must be either 0 or 1.'); } if ($accountTax != 0) { $errors = array_merge($errors, $this->validateId('taxId', $accountTax, 'tiki_acct_tax', true, 'taxBookId', $bookId)); } if (! empty($errors)) { return $errors; } $query = 'INSERT INTO tiki_acct_account' . ' SET accountBookId=?, accountId=?, accountName=?,' . ' accountNotes=?, accountBudget=?, accountLocked=?, accountTax=?'; $res = $this->query( $query, [ $bookId, $accountId, $accountName, $accountNotes, $cleanbudget, $accountLocked, $accountTax ] ); if ($res === false) { $errors[] = tra('Error creating account') & " $accountId: " . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; return $errors; } return true; } /** * Unlocks or locks an account which means it can not be used accidentally for booking * * @param int $bookId current book * @param int $accountId account to lock * @return bool true on success */ public function changeAccountLock($bookId, $accountId) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { return false; } $query = "UPDATE `tiki_acct_account` SET `accountLocked` = NOT `accountLocked` WHERE `accountBookId`=? AND `accountId`=?"; $res = $this->query($query, [$bookId, $accountId]); if ($res === false) { return false; } return true; } /** * Updates an account with the given information * * @param int $bookId id of the current book * @param int $accountId original id of the account * @param int $newAccountId new id of the account (only if the account is changeable) * @param string $accountName name of the account * @param string $accountNotes notes for the account * @param float $accountBudget planned yearly budget for the account * @param boolean $accountLocked can this account be used 0=unlocked, 1=locked * @param int $accountTax id of the auto tax type, defaults to 0 * @return array|bool list of errors, true on success */ public function updateAccount( $bookId, $accountId, $newAccountId, $accountName, $accountNotes, $accountBudget, $accountLocked, $accountTax = 0 ) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { $errors = [tra("This book has been closed. You can't modify the account.")]; return $errors; } $errors = $this->validateId('accountId', $newAccountId, 'tiki_acct_account', true, 'accountBookId', $bookId); if ($accountId != $newAccountId) { if (! $this->accountChangeable($bookId, $accountId)) { $errors[] = tra('AccountId %0 is already in use and must not be changed. Please disable it if it is no longer needed.', $args = [$accountId]); } } if ($accountName === '') { $errors[] = tra('Account name must not be empty.'); } $cleanbudget = $this->cleanupAmount($bookId, $accountBudget); if ($cleanbudget === '') { $errors[] = tra('Budget is not a valid amount: ') . $cleanbudget; } if ($accountLocked != 0 and $accountLocked != 1) { $errors[] = tra('Locked must be either 0 or 1.'); } if ($accountTax != 0) { $errors = array_merge($errors, $this->validateId('taxId', $accountTax, 'tiki_acct_tax', true, 'taxBookId', $bookId)); } if (count($errors) != 0) { return $errors; } $query = "UPDATE tiki_acct_account SET accountId=?, accountName=?, accountNotes=?, accountBudget=?, accountLocked=?, accountTax=? WHERE accountBookId=? AND accountId=?"; $res = $this->query( $query, [ $newAccountId, $accountName, $accountNotes, $cleanbudget, $accountLocked, $accountTax, $bookId, $accountId ] ); if ($res === false) { $errors[] = tra('Error updating account') & " $accountId: " . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; return $errors; } return true; } /** * Delete an account (if deleteable) * * @param int $bookId id of the current book * @param int $accountId account id to delete * @param bool $checkChangeable check, if the account is unused and can be deleted * @return array|bool array with errors or true, if deletion was successful */ public function deleteAccount($bookId, $accountId, $checkChangeable = true) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { return [tra("This book has been closed. You can't delete the account.")]; } if (! $this->accountChangeable($bookId, $accountId)) { return [tra('Account is already in use and must not be deleted. Please disable it, if it is no longer needed.')]; } $query = "DELETE FROM `tiki_acct_account` WHERE `accountBookId`=? AND `accountId`=?"; $res = $this->query($query, [$bookId, $accountId]); return true; } /** * * Do a manual rollback, if the creation of a complete booking fails. * This is a workaround for missing transaction support * @param int $bookId id of the current book * @param int $journalId id of the entry to roll back * @return string Text messages stating the success/failure of the rollback */ public function manualRollback($bookId, $journalId) { $errors = []; $query = "DELETE FROM `tiki_acct_item` WHERE `itemBookId`=? AND `itemJournalId`=?"; $res = $this->query($query, [$bookId, $journalId]); $rollback = ($res !== false); $query = "DELETE FROM `tiki_acct_journal` WHERE `journalBookId`=? AND `journalId`=?"; $res = $this->query($query, [$bookId, $journalId]); $rollback = $rollback and ($res !== false); if (! $rollback) { return tra('Rollback failed, inconsistent database: Cleanup needed for journalId %0 in book %1', [$journalId, $bookId]); } else { return tra('successfully rolled back #') . " $journalId"; } } /** * Checks if the book date is within the books limits * * @param array $book book array * @param DateTime $Date * @retun array|bool */ public function checkBookDates($book, $Date) { $StartDate = new DateTime($book['bookStartDate']); if ($Date < $StartDate) { return [tra("The date of the transaction is before the start date of this book.")]; } $EndDate = new DateTime($book['bookEndDate']); if ($Date > $EndDate) { return [tra("The date of the transaction is after the end date of this book.")]; } return true; } /** * books a simple transaction * * @param int $bookId id of the current book * @param string $journalDate date of the transaction * @param string $journalDescription description of this transaction * @param int $debitAccount account to debit * @param int $creditAccount account to credit * @param double $amount amount to transfer between the accounts * @param string $debitText text for the debit post, defaults to an empty string * @param string $creditText text for the credit post, defaults to an empty string * @return int|array list of errors or journalId on success */ public function simpleBook( $bookId, $journalDate, $journalDescription, $debitAccount, $creditAccount, $amount, $debitText = '', $creditText = '' ) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { return [tra("This book has been closed. Bookings can no longer be made in it.")]; } try { $date = new DateTime($journalDate); } catch (Exception $e) { return [tra("Invalid booking date.")]; } $errors = $this->checkBookDates($book, $date); if (is_array($errors)) { return $errors; } $errors = []; $query = "INSERT INTO `tiki_acct_journal` (`journalBookId`, `journalDate`, `journalDescription`, `journalCancelled`, `journalTs`) VALUES (?,?,?,0,NOW())"; $res = $this->query($query, [$bookId, $date->toString('Y-M-d'), $journalDescription]); if ($res === false) { $errors[] = tra('Booking error creating journal entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $this->rollback(); return $errors; } $journalId = $this->lastInsertId(); $query = "INSERT INTO `tiki_acct_item` (`itemBookId`, `itemJournalId`, `itemAccountId`, `itemType`, `itemAmount`, `itemText`, `itemTs`) VALUES (?, ?, ?, ?, ?, ?, NOW())"; $res = $this->query($query, [$bookId, $journalId, $debitAccount, -1, $amount, $debitText]); if ($res === false) { $errors[] = tra('Booking error creating debit entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->manualRollback($bookId, $journalId); return $errors; } $res = $this->query($query, [$bookId, $journalId, $creditAccount, 1, $amount, $creditText]); if ($res === false) { $errors[] = tra('Booking error creating credit entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->manualRollback($bookId, $journalId); return $errors; } // everything ok return $journalId; } /** * books a complex transaction with multiple accounts on one side * * @param int $bookId id of the current book * @param DateTime $journalDate date of the transaction * @param string $journalDescription description of this transaction * @param mixed $debitAccount account(s) to debit * @param mixed $creditAccount account(s) to credit * @param mixed $debitAmount amount(s) on debit side * @param mixed $creditAmount amount(s) on credit side * @param mixed $debitText text(s) for the debit post, defaults to an empty string * @param mixed $creditText text(s) for the credit post, defaults to an empty string * * @return int|array journalID or list of errors */ public function book( $bookId, $journalDate, $journalDescription, $debitAccount, $creditAccount, $debitAmount, $creditAmount, $debitText = [], $creditText = [] ) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { $errors[] = tra("This book has been closed. Bookings can no longer be made in it."); } if (! ($journalDate instanceof DateTime)) { return [tra("Invalid booking date.")]; } $errors = $this->checkBookDates($book, $journalDate); if (is_array($errors)) { return $errors; } $errors = []; if (! is_array($debitAccount)) { $debitAccount = [$debitAccount]; } if (! is_array($creditAccount)) { $creditAccount = [$creditAccount]; } if (! is_array($debitAmount)) { $debitAmount = [$debitAmount]; } if (! is_array($creditAmount)) { $creditAmount = [$creditAmount]; } if (! is_array($debitText)) { $debitText = [$debitText]; } if (! is_array($creditText)) { $creditText = [$creditText]; } if (count($debitAccount) != count($debitAmount) or count($debitAccount) != count($debitText)) { $errors[] = tra('The number of debit entries differs: ') . count($debitAccount) . '/' . count($debitAmount) . '/' . count($debitText); } if (count($creditAccount) != count($creditAmount) or count($creditAccount) != count($creditText)) { $errors[] = tra('The number of credit entries differs: ') . count($creditAccount) . '/' . count($creditAmount) . '/' . count($creditText); } if (count($debitAccount) > 1 and count($creditAccount) > 1) { $errors[] = tra('Splitting is only allowed on one side.'); } $checkamount = 0; for ($i = 0, $icount_debitAmount = count($debitAmount); $i < $icount_debitAmount; $i++) { $a = $this->cleanupAmount($bookId, $debitAmount[$i]); if (! is_numeric($a) or $a <= 0) { $errors[] = tra('Invalid debit amount ') . $debitAmount[$i]; } else { $checkamount -= $a; } if (! is_numeric($debitAccount[$i])) { $errors[] = tra('Invalid debit account number ') . $debitAccount[$i]; } } for ($i = 0, $icount_creditAmount = count($creditAmount); $i < $icount_creditAmount; $i++) { $a = $this->cleanupAmount($bookId, $creditAmount[$i]); if (! is_numeric($a) or $a <= 0) { $errors[] = tra('Invalid credit amount ') . $creditAmount[$i]; } else { $checkamount += $a; } if (! is_numeric($creditAccount[$i])) { $errors[] = tra('Invalid credit account number ') . $creditAccount[$i]; } } if ($checkamount != 0) { $errors[] = tra('Difference between debit and credit amounts ') . $checkamount; } if (count($errors) > 0) { return $errors; } $query = "INSERT INTO `tiki_acct_journal` (`journalBookId`, `journalDate`, `journalDescription`, `journalCancelled`, `journalTs`) VALUES (?,?,?,0,NOW())"; $res = $this->query($query, [$bookId, date_format($journalDate, 'Y-m-d'), $journalDescription]); if ($res === false) { $errors[] = tra('Booking error creating journal entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; return $errors; } $journalId = $this->lastInsertId(); $query = "INSERT INTO `tiki_acct_item` (`itemBookId`, `itemJournalId`, `itemAccountId`, `itemType`, `itemAmount`, `itemText`, `itemTs`) VALUES (?, ?, ?, ?, ?, ?, NOW())"; for ($i = 0, $icount_debitAccount = count($debitAccount); $i < $icount_debitAccount; $i++) { $a = $this->cleanupAmount($bookId, $debitAmount[$i]); $res = $this->query($query, [$bookId, $journalId, $debitAccount[$i], -1, $a, $debitText[$i]]); if ($res === false) { $errors[] = tra('Booking error creating debit entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->manualRollback($bookId, $journalId); return $errors; } } for ($i = 0, $icount_creditAccount = count($creditAccount); $i < $icount_creditAccount; $i++) { $a = $this->cleanupAmount($bookId, $creditAmount[$i]); $res = $this->query($query, [$bookId, $journalId, $creditAccount[$i], 1, $a, $creditText[$i]]); if ($res === false) { $errors[] = tra('Booking error creating credit entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->manualRollback($bookId, $journalId); return $errors; } } return $journalId; } /** * * Retrieves one entry from the journal * * @param int $bookId id of the current book * @param int $journalId id of the post in the journal * @return array|bool array with post, false on error */ public function getTransaction($bookId, $journalId) { $query = 'SELECT `journalId`, `journalDate`, `journalDescription`, `journalCancelled`' . ' FROM `tiki_acct_journal`' . ' WHERE `journalBookId`=? AND `journalId`=?' ; $res = $this->query($query, [$bookId, $journalId]); if ($res === false) { return false; } $entry = $res->fetchRow(); $query = "SELECT * FROM `tiki_acct_item` WHERE `itemBookId`=? AND `itemJournalId`=? AND `itemType`=? ORDER BY `itemAccountId` ASC"; $entry['debit'] = $this->fetchAll($query, [$bookId, $entry['journalId'], -1]); $entry['debitcount'] = count($entry['debit']); $entry['credit'] = $this->fetchAll($query, [$bookId, $entry['journalId'], 1]); $entry['creditcount'] = count($entry['credit']); $entry['maxcount'] = max($entry['creditcount'], $entry['debitcount']); return $entry; } /** * Declares a statement in the journal as cancelled. * @param int $bookId id of the current book * @param int $journalId journalId of the statement to cancel */ public function cancelTransaction($bookId, $journalId) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { $errors[] = tra("This book has been closed. Transactions can no longer be cancelled in it."); } $query = "UPDATE `tiki_acct_journal` SET `journalCancelled`=1 WHERE `journalBookId`=? and `journalId`=?"; $res = $this->query($query, [$bookId, $journalId]); return true; } /** * Returns the complete stack * * @param int $bookId id of the current book * @return array|bool stack with all posts, false on errors */ public function getStack($bookId) { $stack = []; $query = "SELECT * FROM `tiki_acct_stack` WHERE `stackBookId`=?"; $res = $this->query($query, [$bookId]); if ($res === false) { return false; } while ($row = $res->fetchRow()) { $query = "SELECT * FROM `tiki_acct_stackitem` WHERE `stackBookId`=? AND `stackItemStackId`=? AND `stackItemType`=? ORDER BY `stackItemAccountId` ASC"; $row['debit'] = $this->fetchAll($query, [$bookId, $row['stackId'], -1]); $row['debitcount'] = count($row['debit']); $row['credit'] = $this->fetchAll($query, [$bookId, $row['stackId'], 1]); $row['creditcount'] = count($row['credit']); $row['maxcount'] = max($row['creditcount'], $row['debitcount']); $stack[] = $row; } return $stack; } /** * * Do a manual rollback, if the creation of a complete booking fails. * This is a workaround for missing transaction support * @param int $bookId id of the current book * @param int $stackId id of the entry to roll back * @return string Text messages stating the success/failure of the rollback */ public function stackManualRollback($bookId, $stackId) { $errors = []; $query = "DELETE FROM `tiki_acct_stackitem` WHERE `stackitemBookId`=? AND `stackitemJournalId`=?"; $res = $this->query($query, [$bookId, $stackId]); $rollback = ($res !== false); $query = "DELETE FROM `tiki_acct_stack` WHERE `stackBookId`=? AND `stackId`=?"; $res = $this->query($query, [$bookId, $stackId]); $rollback = $rollback and ($res !== false); if (! $rollback) { return tra('Rollback failed, inconsistent database: Cleanup needed for stackId %0 in book %1', [$stackId, $bookId]); } else { return tra('successfully rolled back #') . " $stackId"; } } /** * books a complex transaction with multiple accounts on one side into the stack * * @param int $bookId id of the current book * @param DateTime $stackDate date of the transaction * @param string $stackDescription description of this transaction * @param mixed $debitAccount account(s) to debit * @param mixed $creditAccount account(s) to credit * @param mixed $debitAmount amount(s) on debit side * @param mixed $creditAmount amount(s) on credit side * @param mixed $debitText text(s) for the debit post, defaults to an empty string * @param mixed $creditText text(s) for the credit post, defaults to an empty string * * @return int|array stackID or list of errors */ public function stackBook( $bookId, $stackDate, $stackDescription, $debitAccount, $creditAccount, $debitAmount, $creditAmount, $debitText = [], $creditText = [] ) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { $errors[] = tra("This book has been closed. Bookings can no longer be made in it."); } $date = $stackDate; $errors = $this->checkBookDates($book, $date); if (is_array($errors)) { return $errors; } $errors = []; if (! is_array($debitAccount)) { $debitAccount = [$debitAccount]; } if (! is_array($creditAccount)) { $creditAccount = [$creditAccount]; } if (! is_array($debitAmount)) { $debitAmount = [$debitAmount]; } if (! is_array($creditAmount)) { $creditAmount = [$creditAmount]; } if (! is_array($debitText)) { $debitText = [$debitText]; } if (! is_array($creditText)) { $creditText = [$creditText]; } if (count($debitAccount) != count($debitAmount) or count($debitAccount) != count($debitText)) { $errors[] = tra('The number of debit entries differs: ') . count($debitAccount) . '/' . count($debitAmount) . '/' . count($debitText); } if (count($creditAccount) != count($creditAmount) or count($creditAccount) != count($creditText)) { $errors[] = tra('The number of credit entries differs: ') . count($creditAccount) . '/' . count($creditAmount) . '/' . count($creditText); } if (count($debitAccount) > 1 and count($creditAccount) > 1) { $errors[] = tra('Splitting is only allowed on one side.'); } $checkamount = 0; for ($i = 0, $icount_debitAmount = count($debitAmount); $i < $icount_debitAmount; $i++) { $a = $this->cleanupAmount($bookId, $debitAmount[$i]); if (! is_numeric($a) or $a <= 0) { $errors[] = tra('Invalid debit amount ') . $debitAmount[$i]; } else { $checkamount -= $a; } if (! is_numeric($debitAccount[$i])) { $errors[] = tra('Invalid debit account number ') . $debitAccount[$i]; } } for ($i = 0, $icount_creditAmount = count($creditAmount); $i < $icount_creditAmount; $i++) { $a = $this->cleanupAmount($bookId, $creditAmount[$i]); if (! is_numeric($a) or $a <= 0) { $errors[] = tra('Invalid credit amount ') . $creditAmount[$i]; } else { $checkamount += $a; } if (! is_numeric($creditAccount[$i])) { $errors[] = tra('Invalid credit account number ') . $creditAccount[$i]; } } if ($checkamount != 0) { $errors[] = tra('Difference between debit and credit amounts ') . $checkamount; } if (count($errors) > 0) { return $errors; } $query = "INSERT INTO `tiki_acct_stack` (`stackBookId`, `stackDate`, `stackDescription`) VALUES (?,?,?)"; $res = $this->query($query, [$bookId, date('Y-m-d', $date->getTimestamp()), $stackDescription]); if ($res === false) { $errors[] = tra('Booking error creating stack entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; return $errors; } $stackId = $this->lastInsertId(); $query = "INSERT INTO `tiki_acct_stackitem` (`stackBookId`, `stackItemStackId`, `stackItemAccountId`, `stackItemType`, `stackItemAmount`, `stackItemText`) VALUES (?, ?, ?, ?, ?, ?)"; for ($i = 0, $icount_debitAccount = count($debitAccount); $i < $icount_debitAccount; $i++) { $a = $this->cleanupAmount($bookId, $debitAmount[$i]); $res = $this->query($query, [$bookId, $stackId, $debitAccount[$i], -1, $a, $debitText[$i]]); if ($res === false) { $errors[] = tra('Booking error creating stack debit entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->stackManualRollback($bookId, $stackId); return $errors; } } for ($i = 0, $icount_creditAccount = count($creditAccount); $i < $icount_creditAccount; $i++) { $a = $this->cleanupAmount($bookId, $creditAmount[$i]); $res = $this->query($query, [$bookId, $stackId, $creditAccount[$i], 1, $a, $creditText[$i]]); if ($res === false) { $errors[] = tra('Booking error creating stack credit entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->manualRollback($bookId, $stackId); return $errors; } } // everything ok return $stackId; } public function stackUpdate( $bookId, $stackId, $stackDate, $stackDescription, $debitAccount, $creditAccount, $debitAmount, $creditAmount, $debitText = [], $creditText = [] ) { $book = $this->getBook($bookId); if ($book['bookClosed'] == 'y') { $errors[] = tra("This book has been closed. Bookings can no longer be made in it."); } $date = $stackDate; $errors = $this->checkBookDates($book, $date); if (is_array($errors)) { return $errors; } $errors = []; if (! is_array($debitAccount)) { $debitAccount = [$debitAccount]; } if (! is_array($creditAccount)) { $creditAccount = [$creditAccount]; } if (! is_array($debitAmount)) { $debitAmount = [$debitAmount]; } if (! is_array($creditAmount)) { $creditAmount = [$creditAmount]; } if (! is_array($debitText)) { $debitText = [$debitText]; } if (! is_array($creditText)) { $creditText = [$creditText]; } if (count($debitAccount) != count($debitAmount) or count($debitAccount) != count($debitText)) { $errors[] = tra('The number of debit entries differs: ') . count($debitAccount) . '/' . count($debitAmount) . '/' . count($debitText); } if (count($creditAccount) != count($creditAmount) or count($creditAccount) != count($creditText)) { $errors[] = tra('The number of credit entries differs: ') . count($creditAccount) . '/' . count($creditAmount) . '/' . count($creditText); } if (count($debitAccount) > 1 and count($creditAccount) > 1) { $errors[] = tra('Splitting is only allowed on one side.'); } $checkamount = 0; for ($i = 0, $icount_debitAmount = count($debitAmount); $i < $icount_debitAmount; $i++) { $a = $this->cleanupAmount($bookId, $debitAmount[$i]); if (! is_numeric($a) or $a <= 0) { $errors[] = tra('Invalid debit amount ') . $debitAmount[$i]; } else { $checkamount -= $a; } if (! is_numeric($debitAccount[$i])) { $errors[] = tra('Invalid debit account number ') . $debitAccount[$i]; } } for ($i = 0, $icount_creditAmount = count($creditAmount); $i < $icount_creditAmount; $i++) { $a = $this->cleanupAmount($bookId, $creditAmount[$i]); if (! is_numeric($a) or $a <= 0) { $errors[] = tra('Invalid credit amount ') . $creditAmount[$i]; } else { $checkamount += $a; } if (! is_numeric($creditAccount[$i])) { $errors[] = tra('Invalid credit account number ') . $creditAccount[$i]; } } if ($checkamount != 0) { $errors[] = tra('Difference between debit and credit amounts ') . $checkamount; } if (count($errors) > 0) { return $errors; } $query = "UPDATE `tiki_acct_stack` SET `stackDate`=?, `stackDescription`=? WHERE `stackBookId`=? AND `stackId`=?"; $res = $this->query($query, [date('Y-m-d', $date->getTimestamp()), $stackDescription, $bookId, $stackId]); if ($res === false) { $errors[] = tra('Booking error creating stack entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; return $errors; } $query = "DELETE FROM `tiki_acct_stackitem` WHERE `stackBookId`=? AND `stackItemStackId`=?"; $res = $this->query($query, [$bookId, $stackId]); if ($res === false) { $errors[] = tra('Booking error creating stack entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->stackManualRollback($bookId, $stackId); return $errors; } $query = "INSERT INTO `tiki_acct_stackitem` (`stackBookId`, `stackItemStackId`, `stackItemAccountId`, `stackItemType`, `stackItemAmount`, `stackItemText`) VALUES (?, ?, ?, ?, ?, ?)"; for ($i = 0, $icount_debitAccount = count($debitAccount); $i < $icount_debitAccount; $i++) { $a = $this->cleanupAmount($bookId, $debitAmount[$i]); $res = $this->query($query, [$bookId, $stackId, $debitAccount[$i], -1, $a, $debitText[$i]]); if ($res === false) { $errors[] = tra('Booking error creating stack debit entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->stackManualRollback($bookId, $stackId); return $errors; } } for ($i = 0, $icount_creditAccount = count($creditAccount); $i < $icount_creditAccount; $i++) { $a = $this->cleanupAmount($bookId, $creditAmount[$i]); $res = $this->query($query, [$bookId, $stackId, $creditAccount[$i], 1, $a, $creditText[$i]]); if ($res === false) { $errors[] = tra('Booking error creating stack credit entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->manualRollback($bookId, $stackId); return $errors; } } // everything ok return $stackId; } /** * deletes an entry from the stack * @param int $bookId id of the current book * @param int $stackId id of the entry to delete * @return bool|array true on success, array of error messages otherwise */ public function stackDelete($bookId, $stackId) { $errors = []; $query = "DELETE FROM `tiki_acct_stackitem` WHERE `stackBookId`=? AND `stackItemStackId`=?"; $res = $this->query($query, [$bookId, $stackId]); if ($res === false) { $errors[] = tra('Error deleting entry from stack') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; } $query = "DELETE FROM `tiki_acct_stack` WHERE `stackBookId`=? AND `stackId`=?"; $res = $this->query($query, [$bookId, $stackId]); if ($res === false) { $errors[] = tra('Error deleting entry from stack') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; } if (count($errors) != 0) { return $errors; } return true; } /** * * Confirm a transaction and transfer it to the journal * @param int $bookId id of the current book * @param int $stackId id of the entry in the stack */ public function stackConfirm($bookId, $stackId) { $query = "INSERT into `tiki_acct_journal` (`journalBookId`, `journalDate`, `journalDescription`, `journalCancelled`, `journalTs`) SELECT ?, `stackDate`, `stackDescription` , 0, NOW() FROM `tiki_acct_stack` WHERE `stackBookId`=? AND `stackId`=?"; $res = $this->query($query, [$bookId, $bookId, $stackId]); if ($res === false) { $errors[] = tra('Booking error confirming stack entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; return $errors; } $journalId = $this->lastInsertId(); $query = "INSERT INTO `tiki_acct_item` (`itemBookId`, `itemJournalId`, `itemAccountId`, `itemType`, `itemAmount`, `itemText`, `itemTs`) SELECT ?, ?, `stackItemAccountId`, `stackItemType`, `stackItemAmount`, `stackItemText`, NOW() FROM `tiki_acct_stackitem` WHERE `stackBookId`=? AND `stackItemStackId`=?"; $res = $this->query($query, [$bookId, $journalId, $bookId, $stackId]); if ($res === false) { $errors[] = tra('Booking error confirming stack entry') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; $errors[] = $this->manualRollback($bookId, $journalId); return $errors; } $this->stackDelete($bookId, $stackId); $query = "UPDATE `tiki_acct_statement` SET `statementJournalId`=? WHERE `statementBookId`=? AND `statementStackId`=?"; $res = $this->query($query, [$journalId, $bookId, $stackId]); return true; } /** * * Retrieves one entry from the stack * * @param int $bookId id of the current book * @param int $journalId id of the post in the journal * @return array|bool array with post, false on error */ public function getStackTransaction($bookId, $stackId) { $query = "SELECT * FROM `tiki_acct_stack` WHERE `stackBookId`=? AND `stackId`=?"; $res = $this->query($query, [$bookId, $stackId]); if ($res === false) { return false; } $entry = $res->fetchRow(); $query = "SELECT * FROM `tiki_acct_stackitem` WHERE `stackBookId`=? AND `stackItemStackId`=? AND `stackItemType`=? ORDER BY `stackItemAccountId` ASC"; $entry['debit'] = $this->fetchAll($query, [$bookId, $entry['stackId'], -1]); $entry['debitcount'] = count($entry['debit']); $entry['credit'] = $this->fetchAll($query, [$bookId, $entry['stackId'], 1]); $entry['creditcount'] = count($entry['credit']); $entry['maxcount'] = max($entry['creditcount'], $entry['debitcount']); return $entry; } /** * Returns a list of bankaccounts which are related to internal accounts * @param int $bookId id if the current book * * @return array list of accounts */ public function getBankAccounts($bookId) { $query = "SELECT * FROM `tiki_acct_bankaccount` INNER JOIN `tiki_acct_account` ON `tiki_acct_bankaccount`.`bankBookId` = `tiki_acct_account`.`accountBookId` AND `tiki_acct_bankaccount`.`bankAccountId`=`tiki_acct_account`.`accountId` WHERE `tiki_acct_bankaccount`.`bankBookId`=?"; return $this->fetchAll($query, [$bookId]); } /** * Returns a list of bank statements which have been uploaded but not yet been processed * * @param int $bookId id of the current book * @param int $accountId id of the account to fetch the statements for * @return array|bool list of statements or false if an error occurred */ public function getOpenStatements($bookId, $accountId) { $query = "SELECT * FROM `tiki_acct_statement` WHERE `statementJournalId`=0 AND `statementStackId`=0 AND `statementBookId`=? AND `statementAccountId`=?"; return $this->fetchAll($query, [$bookId, $accountId]); } /** * Returns the statement with the given Id from the list of statements * * @param int $statetmentId id of the statement to retrieve * @return array|bool statement data or false on error */ public function getStatement($statementId) { $query = "SELECT * FROM `tiki_acct_statement` WHERE `statementId`=?"; $res = $this->query($query, [$statementId]); if ($res === false) { return $res; } return $res->fetchRow(); } /** * Returns the import specification for a given accountId * @param int $bookId id of the current book * @param int $accountId id of the account we want the specs for * @return array|bool list of statements or false */ public function getBankAccount($bookId, $accountId) { $query = "SELECT * FROM `tiki_acct_bankaccount` WHERE bankBookId=? and bankAccountId=?"; $res = $this->query($query, [$bookId, $accountId]); if ($res === false) { return $res; } return $res->fetchRow(); } /** * Splits a header line into a matching array according to the specifications * * @param string $header line containing headers * @param array $defs file definitions * @return array list of statements */ public function analyzeHeader($header, $defs) { $cols = explode($defs['bankDelimeter'], $header); $columns = []; for ($i = 0, $isizeof_cols = count($cols); $i < $isizeof_cols; $i++) { switch ($cols[$i]) { case $defs['fieldNameAccount']: $columns['accountId'] = $i; break; case $defs['fieldNameBookingDate']: $columns['bookingDate'] = $i; break; case $defs['fieldNameValueDate']: $columns['valueDate'] = $i; break; case $defs['fieldNameBookingText']: $columns['bookingText'] = $i; break; case $defs['fieldNameReason']: $columns['reason'] = $i; break; case $defs['fieldNameCounterpartName']: $columns['counterpartName'] = $i; break; case $defs['fieldNameCounterpartAccount']: $columns['counterpartAccount'] = $i; break; case $defs['fieldNameCounterpartBankcode']: $columns['counterpartBankcode'] = $i; break; case $defs['fieldNameAmount']: $columns['amount'] = $i; break; case $defs['fieldNameAmountSign']: $columns['amountSign'] = $i; break; } } return $columns; } /** * updates journalId in the given statement * * @param int $statementId id of the statement to update * @param int $journalId id of the entry in the journal which was caused by this statement * @return array|boolean list of errors, empty if no errors were found */ public function updateStatement($statementId, $journalId) { $errors = []; $query = "UPDATE `tiki_acct_statement` SET `statementJournalId`=? WHERE `statementId`=?"; $res = $this->query($query, [$journalId, $statementId]); if ($res === false) { $errors[] = tra('Error while updating statement:') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; return $errors; } return true; } /** * updates journalId in the given statement * * @param int $statementId id of the statement to update * @param int $journalId id of the entry in the journal which was caused by this statement * @return array|bool list of errors, empty if no errors were found */ public function updateStatementStack($statementId, $stackId) { $errors = []; $query = "UPDATE `tiki_acct_statement` SET `statementStackId`=? WHERE `statementId`=?"; $res = $this->query($query, [$stackId, $statementId]); if ($res === false) { $errors[] = tra('Error while updating statement:') . $this->ErrorNo() . ": " . $this->ErrorMsg() . "
$query
"; return $errors; } return true; } /** * * Creates a tax setting for automated tax deduction/splitting * @param int $bookId * @param string $taxText * @param double $taxAmount * @param string $taxIsFix * @return int id of the newly created tax */ public function createTax($bookId, $taxText, $taxAmount, $taxIsFix = 'n') { $query = "INSERT INTO `tiki_acct_tax` (`taxBookId`, `taxText`, `taxAmount`, `taxIsFix`) VALUES (?, ?, ?, ?)"; $res = $this->query($query, [$bookId, $taxText, $taxAmount, $taxIsFix]); return $this->lastInsertId(); } /** * removes all unnecessary thousand markers and replaces local decimal characters with "." to enable handling as numbers. * * @param int $bookId id of the current book * @param string $amount date of the transaction * @return string/float Returns a float or an empty string if the source is not numeric */ public function cleanupAmount($bookId, $amount) { $book = $this->getBook($bookId); $a = str_replace($book['bookDecPoint'], '.', str_replace($book['bookThousand'], '', $amount)); if (! is_numeric($a)) { return ''; } return (float)$a; } /** * Checks the existence/non-existence of a numerical id in the given table * * @param string $idname name of the id field in the table * @param int $id the id to check * @param string $table the table to search * @param boolean $exists true if a record must exist, false if it must not * * @return array Returns aa array of errors (empty if none occurred) */ public function validateId($idname, $id, $table, $exists = true, $bookIdName = '', $bookId = 0) { $errors = []; if (! is_numeric($id)) { $errors[] = htmlspecialchars($idname) . ' (' . htmlspecialchars($id) . ')' . tra('is not a number.'); } elseif ($id <= 0) { $errors[] = htmlspecialchars($idname) . ' ' . tra('must be greater than 0.'); } else { //static whitelist based on usage of the validateId function in accountinglib.php $tablesWhitelist = [ 'tiki_acct_tax' => [ 'idname' => 'taxId', 'bookIdName' => 'taxBookId' ], 'tiki_acct_account' => [ 'idname' => 'accountId', 'bookIdName' => 'accountBookId' ] ]; if (! array_key_exists($table, $tablesWhitelist)) { $errors[] = tra('Invalid transaction - please contact administrator.'); } elseif ($idname !== $tablesWhitelist[$table]['idname']) { $errors[] = tra('Invalid transaction - please contact administrator.'); } else { $query = "SELECT $idname FROM $table WHERE $idname = ?"; $bindvars = [$id]; if ($bookIdName === $tablesWhitelist[$table]['bookIdName']) { $query .= " AND $bookIdName = ?"; array_push($bindvars, $bookId); } $res = $this->query($query, $bindvars); if ($res === false) { $errors[] = tra('Error checking') . htmlspecialchars($idname) . ': ' . $this->ErrorNo() . ': ' . $this->ErrorMsg() . '
' . htmlspecialchars($query) . '
'; } else { if ($exists) { if ($res->numRows() == 0) { $errors[] = htmlspecialchars($idname) . ' ' . tra('does not exist.'); } } else { if ($res->numRows() > 0) { $errors[] = htmlspecialchars($idname) . ' ' . tra('already exists'); } } } } } return $errors; } }