# # Copyright (C) 2002-2014 Corporation of Balclutha. All rights Reserved. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE # GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT # OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # import AccessControl, logging, string, operator, re, transaction, types from AccessControl.Permissions import view, view_management_screens,\ manage_properties, access_contents_information from Acquisition import aq_base from DateTime import DateTime from zExceptions import NotFound from DocumentTemplate.DT_Util import html_quote from OFS.PropertyManager import PropertyManager from Products.PageTemplates.PageTemplateFile import PageTemplateFile from Products.ZCatalog.ZCatalog import ZCatalog from Products.AdvancedQuery import And, Between, Eq, In, Le, Ge, MatchRegexp from utils import floor_date, ceiling_date, assert_currency, isDerived from BLBase import ProductsDictionary, PortalContent, PortalFolder, LargePortalFolder from Products.BastionBanking.ZCurrency import ZCurrency, CURRENCIES from Products.BastionBanking.Exceptions import UnsupportedCurrency from Products.BastionBanking.interfaces.IPayee import IPayee from Permissions import OperateBastionLedgers, ManageBastionLedgers from BLEntry import manage_addBLEntry, BLEntry from BLTransaction import manage_addBLTransaction from BLGlobals import EPOCH, MAXDT from BLAttachmentSupport import BLAttachmentSupport from BLTaxCodeSupport import BLTaxCodeSupport from Exceptions import PostingError, OrphanEntryError, IncorrectAmountError, LedgerError from Products.BTreeFolder2.BTreeFolder2 import BTreeFolder2 from Products.CMFCore.utils import getToolByName from zope.interface import Interface, implements from interfaces.account import IAccount LOG = logging.getLogger('BLAccount') # # temporary hack to map new subtype field (works for UK_General/Australia...) # SUBTYPES = { 1000 : 'Current Assets', 1500 : 'Inventory Assets', 1800 : 'Capital Assets', 2000 : 'Current Liabilities', 2200 : 'Current Liabilities', 2600 : 'Long Term Liabilities', 3300 : 'Share Capital', 3500 : 'Retained Earnings', 4000 : 'Sales Revenue', 4300 : 'Consulting Revenue', 4400 : 'Other Revenue', 5000 : 'Cost of Goods Sold', 5400 : 'Payroll Expenses', 5500 : 'Taxation Expenses', 5600 : 'General and Administrative Expenses' } # # status's that can/should aggregate to valid txn entries # #STATUSES = ('posted', 'reversed', 'postedreversal') STATUSES = ('posted',) manage_addBLAccountForm = PageTemplateFile('zpt/add_account', globals()) def manage_addBLAccount(self, title, currency, type, subtype='', accno='', tags=[], id='', description='', REQUEST=None): """ an account """ # hmmm - factory construction kills this ... #if accno in self.Accounts.uniqueValuesFor('accno'): # raise LedgerError, 'Duplicate accno: %s' % accno if not id: id = self.nextAccountId() assert currency in CURRENCIES, 'Unknown currency type: %s' % currency try: self._setObject(id, BLAccount(id, title, description, type, subtype, currency, accno or id, tags)) except: # TODO: a messagedialogue ... raise acct = self._getOb(id) if REQUEST is not None: REQUEST.RESPONSE.redirect("%s/manage_workspace" % acct.absolute_url()) return acct def addBLAccount(self, id='', title='', type='Asset', subtype='Current Assets', accno='', REQUEST=None): """ Plone constructor """ # hmmm - this becomes a TempFolder when plugged into portal_factories ... #assert self.meta_type=='BLLedger', 'wrong container type: %s != BLLedger' % self.meta_type account = manage_addBLAccount(self, id = id, title=title, type=type, subtype=subtype, accno=accno or id, currency=self.defaultCurrency()) return account.getId() class BLAccount(LargePortalFolder, BLAttachmentSupport, BLTaxCodeSupport): """ """ meta_type = portal_type = 'BLAccount' implements(IAccount, IPayee) __ac_permissions__ = ( (access_contents_information, ('zeroAmount', 'Currencies', 'hasTag', 'allTags', 'created', 'controlLedgers',)), (view_management_screens, ('manage_statement', 'manage_btree', 'manage_verify', 'manage_mergeForm', 'totBalance','totDate')), (ManageBastionLedgers, ('manage_details', 'manage_acl', 'manage_edit', 'manage_setBalance', 'manage_setDescriptionFromEntryAccount', 'manage_addTaxGroup', 'manage_delTaxGroups', 'manage_editTaxCodes', 'manage_addTaxCodes', 'manage_merge')), (OperateBastionLedgers, ('createTransaction', 'createEntry', 'manage_statusModify', 'updateProperty', 'updateTags', 'isDeletable')), (view, ('blLedger', 'blEntry', 'blTransaction', 'balance', 'total', 'debitTotal', 'creditTotal', 'manage_emailStatement', 'makeAdvQuery', 'applyFilter', 'prettyTitle', 'entryValues', 'entryIds', 'subtypes', 'modificationTime', 'openingBalance', 'openingDate', 'balances', 'historicalDates', 'getBastionMerchantService', 'isFCA', 'isControl', 'payeeAmount', 'lastTransactionDate')), ) + LargePortalFolder.__ac_permissions__ + BLAttachmentSupport.__ac_permissions__ _properties = LargePortalFolder._properties + ( {'id':'base_currency', 'type':'selection', 'mode':'w', 'select_variable':'Currencies'}, {'id':'type', 'type':'selection', 'mode':'w', 'select_variable':'Types'}, {'id':'subtype', 'type':'string', 'mode':'w', }, {'id':'accno', 'type':'string', 'mode':'w', }, {'id':'tags', 'type':'lines', 'mode':'w', }, ) # Plone requirement - not used description = '' # just for emergencies .... manage_btree = LargePortalFolder.manage_main def manage_options(self): options = [ {'label': 'Statement', 'action': 'manage_statement', 'help':('BastionLedger', 'statement.stx') }, {'label':'View', 'action':'',}, {'label': 'Details', 'action': 'manage_details', 'help':('BastionLedger', 'account_props.stx') }, {'label':'Verify', 'action':'manage_verify',}, {'label':'Tax Groups', 'action':'manage_taxcodes'}, {'label':'Merge', 'action':'manage_mergeForm' }, {'label':'Dublin Core', 'action':'manage_metadata'}, BLAttachmentSupport.manage_options[0],] if getattr(aq_base(self), 'acl_users', None): options.append( {'label': 'Users', 'action':'manage_acl'} ) options.extend(LargePortalFolder.manage_options[2:]) return options Types = ('Asset', 'Liability', 'Proprietorship', 'Income', 'Expense') manage_statement = manage_main = PageTemplateFile('zpt/view_account', globals()) manage_details = PageTemplateFile('zpt/edit_account', globals()) manage_acl = PageTemplateFile('zpt/view_acl', globals()) manage_mergeForm = PageTemplateFile('zpt/merge_accounts', globals()) asXML = PageTemplateFile('zpt/xml_acct', globals()) def controlLedgers(self): """ if this is a control account, then return the subsidiary ledger(s) """ return map(lambda x: x.blLedger(), self.objectValues('BLControlEntry')) def Currencies(self): """ A list of approved currencies which this account may be based """ return self.aq_parent.currencies def isFCA(self): """ return whether or not this is a foreign currency account - not of the same currency as the ledger """ return self.base_currency != self.aq_parent.defaultCurrency() def isControl(self): return len(self.objectIds('BLControlEntry')) > 0 def controlEntry(self, ledgerid): return self._getOb(ledgerid, None) def optional_objects(self): objs = [] if getattr(aq_base(self), 'acl_users', None): objs.append({'id':'acl_users', 'name': self.acl_users.meta_type}) return objs def created(self): """ the date the account was created """ return DateTime(self.CreationDate()) def __init__(self, id, title, description, type, subtype, currency, accno, tags=[], opened=DateTime()): LargePortalFolder.__init__(self, id) self.opened = floor_date(opened) self._updateProperty('base_currency', currency) self._updateProperty('title', title) self.description = description self._updateProperty('type', type) self._updateProperty('subtype', subtype) self._updateProperty('accno', accno) self._updateProperty('tags', tags) def updateTags(self, tags): """ assign/edit tags to account """ if type(tags) == types.StringType: tags = (tags,) self._updateProperty('tags', tags) self.reindexObject(idxs=['tags']) def manage_edit(self, title, description, type, subtype, accno, tags, base_currency='', REQUEST=None): """ """ # only change currency if there are no entries ... #if base_currency and base_currency != self.base_currency and not len(self): if base_currency and base_currency != self.base_currency: self._updateProperty('base_currency', base_currency) self.manage_changeProperties(title=title, description=description, type=type, subtype=subtype, accno=accno, tags=tags) self.reindexObject() if REQUEST is not None: REQUEST.set('management_view', 'Details') REQUEST.set('manage_tabs_message', 'Updated') return self.manage_details(self, REQUEST) def manage_merge(self, ids=[], delete=True, REQUEST=None): """ move entries from nominated account(s) into this one, adjusting their postings and removing those account(s) from the ledger if delete """ merged = 0 ledger = self.aq_parent for id in ids: try: account = ledger._getOb(id) except: continue # we need to take a copy because otherwise we're unindexing stuff we previously # had just indexed with the account number changes/substitutions ... for entry in account.entryValues(): entry.blTransaction().manage_toggleAccount(account.getId(), self.getId()) # remove the old account if delete: ledger._delObject(id) merged += 1 if REQUEST: REQUEST.set('manage_tabs_message', 'merged %i accounts' % merged) return self.manage_main(self, REQUEST) def manage_verify(self, precision=0.05, REQUEST=None): """ verify the account entries have been applied correctly and are still valid precision defaults to 5 cents ... this function deliberately *does not* use the underlying object's methods to check this - it's supposed to independently check the underlying library - or consequent tamperings via the ZMI """ bad_entries = [] ledger_id = self.aq_parent.getId() for posted in self.entryValues(): #if not isinstance(posted, BLEntry): # raise AssertionError, posted try: txn = posted.blTransaction() except: bad_entries.append((OrphanEntryError(posted), '')) continue if txn is None: if posted.isControlEntry(): continue bad_entries.append((OrphanEntryError(posted), '')) continue if txn.status() not in STATUSES: bad_entries.append((PostingError(posted), '')) continue unposted = txn.blEntry(self.getId(), ledger_id) if unposted is None: bad_entries.append((OrphanEntryError(posted), '')) continue # find/use common currency base base_currency = self.base_currency unposted_amt = unposted.amount posted_amt = posted.amount if unposted_amt.currency() != base_currency: unposted_amt = unposted.amountAs(base_currency) if posted_amt.currency() != base_currency: posted_amt = posted.amountAs(base_currency) if abs(unposted_amt - posted_amt) > precision: bad_entries.append((IncorrectAmountError(posted), '%s - %s' % (unposted_amt, unposted_amt - posted_amt))) if REQUEST: if bad_entries: REQUEST.set('manage_tabs_message', '<br>'.join(map(lambda x: "%s: %s %s" % (x[0].__class__.__name__, html_quote(str(x[0].args[0])), x[1]), bad_entries))) else: REQUEST.set('manage_tabs_message', 'OK') return self.manage_main(self, REQUEST) return bad_entries def manage_emailStatement(self, email, message='', effective=None, REQUEST=None): """ email invoice to recipient ... """ try: mailhost = self.superValues(['Mail Host', 'Secure Mail Host'])[0] except: # TODO - exception handling ... if REQUEST: REQUEST.set('manage_tabs_message', 'No Mail Host Found') return self.manage_main(self, REQUEST) raise ValueError, 'no MailHost found' sender = self.aq_parent.email if not sender: if REQUEST: REQUEST.set('manage_tabs_message', """Ledger's Correpondence Email unset!""") return self.manage_main(self, REQUEST) raise LedgerError, """Ledger's Correspo/ndence Email unset!""" # ensure 7-bit ?? mail_text = str(self.blaccount_template(self, self.REQUEST, sender=sender, email=email, effective=effective or DateTime())) mailhost._send(sender, email, mail_text) if REQUEST: REQUEST.set('manage_tabs_message', 'Statement emailed to %s' % email) return self.manage_main(self, REQUEST) def manage_setDescriptionFromEntryAccount(self, REQUEST=None): """ sometimes people stick naf transaction descriptions and this goes and applies underlying account title to the description - this is only used against Subsidiary Ledger postings """ for entry in map(lambda x: x[1], self.entries()): try: # alright - there is most likely only one subsidiary entry in a subsidiary transaction ... entry.title = entry.blTransaction().objectValues('BLSubsidiaryEntry')[0].Account().prettyTitle() except: pass if REQUEST: return self.manage_main(self, REQUEST) def _updateProperty(self, name, value): if name == 'base_currency': if not value in CURRENCIES: raise UnsupportedCurrency, value if name == 'accno': # go change it in periods (if we've got context - ie - not a ctor call) periods = getattr(self, 'periods', None) if periods: for period in periods.periodsForLedger(self.getId()): try: period._getOb(self.getId()).accno = value except KeyError: pass LargePortalFolder._updateProperty(self, name, value) def blEntry(self, id): """ return the associated entry for the BLTransaction id """ # hmmm - verify CANNOT SEARCH ON LEDGER (may be subsidiary ledger posting)!! brainz = self.bastionLedger().evalAdvancedQuery(Eq('accountId', self.getId(), filter=True) & \ Eq('ledgerId', self.aq_parent.getId(), filter=True) & \ Eq('transactionId', id, filter=True) & \ In('meta_type', ('BLEntry', 'BLSubsidiaryEntry'))) if brainz: # technically, there should *always* be just one entry ... return brainz[0]._unrestrictedGetObject() return None def blLedger(self): """ find the BLLedger (derivative) of this account - in any circumstance """ parent = self.aq_parent if not isDerived(parent.__class__, 'LedgerBase'): parent = parent.aq_parent return parent def blTransaction(self, id): """ return the associated entry for the BLTransaction id """ entry = self.blEntry(id) return entry and entry.aq_parent or None def balance(self, currency=None, effective=None, *args, **kw): """ return the account balance in specified currency (or the base currency of the account), as of date (defaults to all) """ currency = currency or self.base_currency #if effective is None or effective >= self._balance_dt: if effective is None: e = DateTime() amounts = [(self._balance, self._balance_dt)] + \ map(lambda x: (x.balance(effective=e), e), self.objectValues('BLControlEntry')) pt = getToolByName(self, 'portal_bastionledger') return pt.addCurrencies(amounts, currency) effective = effective or DateTime() # figure out if we can use a cached beginning value # TODO - we could incorporate period info sums into this as well opening_dt = self.openingDate(effective) # seems that if it's on a period-boundry, opening date is rolled to next day opening_amt = self.openingBalance(effective) # the cached amount is sufficient ... if effective < opening_dt: return opening_amt if not effective: dtrange = (opening_dt, DateTime()) elif isinstance(effective, DateTime): dtrange = (opening_dt, effective) elif type(effective) in (types.ListType, types.TupleType): dtrange = effective else: raise AttributeError, dtrange if opening_amt.currency() != currency: pt = getToolByName(self, 'portal_bastionledger') total = pt.convertCurrency(opening_amt, opening_dt, currency) else: total = opening_amt return total + self.total(currency=currency, effective=dtrange) def total(self, effective=None, currency=None, status=STATUSES, query=None): """ summates entries over range (or up to a date) """ amts = [] currency = currency or self.base_currency if isinstance(effective, DateTime): min_effective = effective elif effective is None: min_effective = DateTime() else: min_effective = min(effective) # summate the entries ... for entry in self.entryValues(effective, status, query): #if not isinstance(entry, BLEntry): # raise AssertionError, (entry, self.entryValues(effective, status, query)) if entry.isControlEntry(): # only do control entry query if it's value isn't already incorporated in opening balance opening_dt = entry.lastTransactionDate() if opening_dt < min_effective: continue amts.append(entry.total(effective=effective, currency=currency)) else: amts.append(entry.amountAs(currency)) return self._total(amts, effective, currency) def debitTotal(self, effective=None, currency=None, status=STATUSES, query=None): """ sum up the debits effective_date can be a single value or a list with a single element, in which case, we return all debits until that date. If effective_date is a multi-element list, then we sum the entries within that range """ currency = currency or self.base_currency return self._total(map(lambda x: x.amountAs(currency), filter(lambda x: x.amount > 0, self.entryValues(effective, status, query))), effective, currency) def creditTotal(self, effective=None, currency=None, status=STATUSES, query=None): """ sum up the credits (filtering out any reversals) effective_date can be a single value or a list with a single element, in which case, we return all credits until that date. If effective_date is a multi-element list, then we sum the entries within that range """ currency = currency or self.base_currency return self._total(map(lambda x: x.amountAs(currency), filter(lambda x: x.amount < 0, self.entryValues(effective, status, query))), effective, currency) def _total(self, amounts, effective=None, currency=None): """ summate amounts, doing any necessary currency conversion (as at effective) """ # can't reduce an empty lost ... if amounts: total = reduce(operator.add, amounts) else: total = ZCurrency(currency or self.base_currency, 0.0) if currency and total.currency() != currency: if effective: if type(effective) in (types.ListType, types.TupleType): eff = max(effective) else: if not isinstance(effective, DateTime): raise ValueError, effective eff = effective else: eff = DateTime() pt = getToolByName(self, 'portal_bastionledger') return pt.convertCurrency(total, eff, currency) return total def hasForwards(self, dt=None): """ returns whether or not there are forward-dated transactions """ return len(self.entryValues(effective=(dt or DateTime(), MAXDT))) != 0 def historicalDates(self): """ returns a list of (cached) period-end dates from which to offer 'nice' summary calculation ranges """ dates = map(lambda x: x.period_ended, self.periods.periodsForLedger(self.aq_parent.getId())) dates.reverse() return dates def openingBalance(self, effective=None, currency=''): """ return the amount as per the effective date (as summed past the last period end) """ currency = currency or self.base_currency effective = effective or DateTime() balance = self.periods.balanceForAccount(effective, self.aq_parent.getId(), self.getId()) if balance is None: #balance = self.total(currency=currency, effective=effective) balance = self.zeroAmount(currency) elif balance.currency() != currency: pt = getToolByName(self, 'portal_bastionledger') balance = pt.convertCurrency(balance, effective, currency) return balance def openingDate(self, effective=None): """ return the date for which the opening balance applies """ dt = self.periods.lastClosingForLedger(self.aq_parent.getId(), effective or DateTime()) return dt != EPOCH and floor_date(dt+1) or EPOCH def prettyTitle(self): """ seemly title - even in face of portal_factory creation ... """ return "%s - %s" % (self.accno or self.getId(), self.title or self.getId()) def makeAdvQuery(self, query={}): """ take a request filter and return an AdvancedQuery appropriate to our catalog """ aquery = [] if query.has_key('desc'): aquery.append(Eq('title', query['desc'])) if query.has_key('ledger'): aquery.append(Eq('ledgerId', query['ledger'])) return aquery and And(*aquery) or None def applyFilter(self, query={}, entries=[]): """ return a filter to apply to entryValues to restrict results """ if query.has_key('debit') and not query.has_key('credit'): entries = filter(lambda x:x.isDebit(), entries) if query.has_key('credit') and not query.has_key('debit'): entries = filter(lambda x: x.isCredit(), entries) if query.has_key('currency'): entries = filter(lambda x: x.amount._currency == query['currency'], entries) if query.has_key('accno'): entries = filter(lambda x: x.blTransaction().isAgainst(query['accno'], self.blLedger().getId()), entries) return entries def _entryQuery(self, aquery): """ any additional filter to define entry's within an account """ return aquery & Eq('meta_type', 'BLEntry') def entryValues(self, effective=None, status=STATUSES, query=None, REQUEST=None): """ returns all entries in given status's - defaulted to filter cancelled status query is an advanced query """ if effective is None: end = DateTime() start = self.openingDate(end) elif isinstance(effective, DateTime): end = effective start = self.openingDate(end) else: end = max(effective) start = min(effective) # add control entries to top of list - regardless of balance for the date range!! entries = map(lambda x: x.blEntry(effective=(start, end)), self.objectValues('BLControlEntry')) # cannot filter on ledgerId - subsidiary ledgers ... aquery = self._entryQuery(Eq('accountId', self.getId(), filter=True) & \ Eq('ledgerId', self.aq_parent.getId(), filter=True) & \ Between('effective', start, end, filter=True) & \ In('status', status)) if query: aquery = aquery & query # old or new-style catalog?? needed temporarily to allow ledger import catalog = self.bastionLedger() #if getattr(aq_base(catalog), '_catalog', None) is None: # catalog = self.aq_parent # the account-side of any entry (which can include tid's from other journals) is # being determined by not starting with btree's generateId tag for brain in catalog.evalAdvancedQuery(aquery, (('effective', 'desc'),)): try: entries.append(brain._unrestrictedGetObject()) except NotFound: # eek - screwed up indexes ... continue return entries def entryIds(self, effective=None, status=STATUSES, query=None, REQUEST=None): """ returns all entries in given status's - defaulted to filter cancelled this is actually the list of transaction ids affecting this account """ return map(lambda x: x.blTransaction().getId(), self.entryValues(effective, status, query)) def subtypes(self, type=''): if type: ret = [] for stype in map(lambda x: x['subtype'], self.bastionLedger().evalAdvancedQuery(Eq('type', type))): if stype == '' or stype in ret: continue ret.append(stype) return ret else: return self.bastionLedger().uniqueValuesFor('subtype') def modificationTime(self): """ """ return self.bobobase_modification_time().strftime('%Y-%m-%d %H:%M') def manage_editProperties(self, REQUEST): """ Overridden to make sure recataloging is done """ for prop in self._propertyMap(): name=prop['id'] if 'w' in prop.get('mode', 'wd'): value=REQUEST.get(name, '') self._updateProperty(name, value) self.reindexObject() def isDeletable(self, effective=None): """ return whether or not this account may be deleted (ie has no transactions/entries) on it """ for entry in self.entryValues(): if entry.meta_type == 'BLControlEntry': if entry.balance(effective=effective) != 0: return False else: return False return True def manage_delProperties(self, ids=[], REQUEST=None): """ only delete props NOT in extensions ...""" if REQUEST: # Bugfix for property named "ids" (Casey) if ids == self.getProperty('ids', None): ids = [] ids = REQUEST.get('_ids', ids) extensions = self.aq_parent.aq_parent.propertyIds() ids = filter( lambda x,y=extensions: x not in y, ids ) LargePortalFolder.manage_delProperties(self, ids) if REQUEST: return self.manage_propertiesForm(self, REQUEST) def indexObject(self, idxs=[]): """ Handle indexing """ self.bastionLedger().catalog_object(self, '/'.join(self.getPhysicalPath()), idxs=idxs) def unindexObject(self): """ Handle unindexing """ self.bastionLedger().uncatalog_object('/'.join(self.getPhysicalPath())) def reindexObject(self, idxs=[], REQUEST=None): """ reapply the account to the catalog """ if not idxs: self.unindexObject() try: self.indexObject(idxs=idxs) except (KeyError, AttributeError): # unknown index (ie Workflow stuff expecting full Plone indexes pass if REQUEST: REQUEST.set('manage_tabs_message', 'recataloged account') return self.manage_main(self, REQUEST) def createTransaction(self, title='', reference=None, effective=None): """ polymorphically create correct transaction for ledger, returning this transaction """ ledger = self.blLedger() tid = manage_addBLTransaction(ledger, '', title, effective or DateTime(), reference) return ledger._getOb(tid) def createEntry(self, txn, amount, title=''): """ transparently create a transaction entry""" manage_addBLEntry(txn, self, amount, title) def manage_payAccount(self, amount, reference='', other_account=None, REQUEST=None): """ make a physical funds payment on the account, implemented using BastionBanking Note this is intended to work polymorphically across all accounts in all types of ledger. """ bms = self.getBastionMerchantService() if not bms: raise ValueError, 'No BastionMerchantService' # other account should be blank only in Ledger if not other_account: other_account = self.accountValues(tags='bank_account')[0] txn = self.createTransaction('Payment') amount = abs(amount) if self.balance(): other_account.createEntry(txn, amount, 'Cash') self.createEntry(txn, -amount, 'Payment - Thank You') else: other_account.createEntry(txn, -amount, 'Cash') self.createEntry(txn, amount, 'Payment - Thank You') # if the merchant service redirects, we need to ensure the transaction remains ... transaction.get().savepoint(optimistic=True) rc = bms.manage_payTransaction(txn, reference, REQUEST=self.REQUEST) # BastionMerchantService may hihack us - redirecting our client ... if rc and REQUEST: REQUEST.set('Payment Processed') return self.manage_main(self, REQUEST) def manage_statusModify(self, workflow_action, REQUEST=None): """ perform the workflow (very Plone ...) """ self.content_status_modify(workflow_action) if REQUEST: REQUEST.set('manage_tabs_message', 'State Changed') return self.manage_main(self, REQUEST) # need to allow setting of properties from Python Scripts...- maybe we should just # use manage_changeProperties ... def updateProperty(self, name, value): """ set or update a property """ if not self.hasProperty(name): self._setProperty(name, value) else: self._updateProperty(name, value) def sum(self, id, effective=None, debits=True, credits=True): """ summate entries based upon id (the other account id), date range, and sign """ currency = self.base_currency amount = ZCurrency(currency, 0) for entry in self.entryValues(effective): try: a = entry.blTransaction().blEntry(id).amountAs(currency) except: continue if (debits and a > 0) or (credits and a < 0): amount += a return amount def balances(self, dates, entries=[], format='%0.2f'): """ return string balance information for a date range or entry range (suitable for graphing) """ if len(entries) == 0: return map(lambda x: self.balance(effective=x).strfcur(format), dates) days = {} for entry in entries: effective = entry.effective() if days.has_key(effective): days[effective] += entry.amount else: days[effective] = entry.amount zero = self.zeroAmount() for date in dates: if not days.has_key(date): days[date] = zero drange = days.keys() drange.sort() for i in xrange(0, len(drange)-1): if i == 0: days[drange[0]] += self.balance(effective=drange[0]) days[drange[i+1]] += days[drange[i]] return map(lambda x: days[x].strfcur(format), drange) def zeroAmount(self, currency=''): """ a zero-valued amount in the currency of the account """ return ZCurrency(currency or self.base_currency, 0) def allTags(self): """ return a list of all the tags this account is associated with """ tags = list(self.tags) pt = getToolByName(self, 'portal_bastionledger') ledger_id = self.aq_parent.getId() for assocs in pt.objectValues('BLAssociationFolder'): for tag in map(lambda x: x.getId(), assocs.searchObjects(ledger=ledger_id, accno=self.accno)): if tag not in tags: tags.append(tag) tags.sort() return tags def hasTag(self, tag): """ """ if tag in self.tags: return True pt = getToolByName(self, 'portal_bastionledger') for assocs in pt.objectValues('BLAssociationFolder'): if assocs.searchResults(ledger=self.aq_parent.getId(), accno=self.accno, id=tag): return True return False def __cmp__(self, other): """ hmmm - sorted based on accno """ if not isinstance(other, BLAccount): return 1 if other.accno > self.accno: return 1 elif other.accno < self.accno: return -1 return 0 def __str__(self): return "<%s instance - (%s/%s [%s], %s)>" % (self.meta_type, self.aq_parent.getId(), self.getId(), self.title, self.balance()) def asCSV(self, datefmt='%Y/%m/%d', curfmt='%a', REQUEST=None): """ """ return '\n'.join(map(lambda x: x.asCSV(datefmt, curfmt), self.entryValues())) def getBastionMerchantService(self): """ returns a Bastion Internet Merchant tool if present (or None) such that any/all account(s) could be paid-down via the internet """ # TODO - use IBankMerchant parent = self try: while parent: bms = filter(lambda x: x.status() == 'active', parent.objectValues('BastionMerchantService')) if bms: return bms[0] parent = parent.aq_parent except: pass return None def payeeAmount(self, effective=None): """ """ return self.balance(effective=effective or DateTime()) def _repair(self): # remove BLObserverSupport for attr in ('onAdd', 'onChange', 'onDelete'): if getattr(aq_base(self), attr, None): delattr(self, attr) if not getattr(aq_base(self), 'tags', None): self.tags = [] entryids = list(self.objectIds(['BLEntry', 'BLSubsidiaryEntry'])) if entryids: self.manage_delObjects(entryids) opening = getattr(aq_base(self), 'OPENING', None) if opening: delattr(self, 'OPENING') # force reindexing ... self.reindexObject() def _totalise(self, entry, now=None): """ update internal running totals/cache counters this is the balance (and date) as at last transaction on this account """ effective = entry.effective() # ignore forward-dated transactions if effective > ceiling_date(now or DateTime()): return if effective >= self.openingDate(): currency = self.defaultCurrency() if entry.amount.currency() == currency: self._balance += entry.amount else: amount = entry.foreignAmount() if amount and amount.currency() == currency: self._balance += amount else: pt = getToolByName(self, 'portal_bastionledger') self._balance += pt.convertCurrency(entry.amount, effective, currency) if effective > self._balance_dt: self._balance_dt = effective def _untotalise(self, entry): """ update internal running totals/cache counters """ effective = entry.effective() # ignore forward-dated transactions if effective > ceiling_date(DateTime()): return # it's previous period, ignore it if effective < self.openingDate(): return currency = self.defaultCurrency() if entry.amount.currency() == currency: self._balance -= entry.amount else: amount = entry.foreignAmount() if amount and amount.currency() == currency: self._balance -= amount else: pt = getToolByName(self, 'portal_bastionledger') self._balance -= pt.convertCurrency(entry.amount, effective, currency) self._balance_dt = self.lastTransactionDate() def totDate(self): """ the running total date """ return self._balance_dt #.strftime('%Y/%m/%d') def totBalance(self): """ the running total balance """ return self._balance def manage_setBalance(self, amount, effective=None, REQUEST=None): """ manually set/override balance counters """ #if amount.currency != self.bastionLedger().defaultCurrency(): # raise UnsupportedCurrency, amount self._balance = amount self._balance_dt = floor_date(effective or DateTime()) if REQUEST: return self.manage_main(self, REQUEST) def lastTransactionDate(self): """ the last transaction date for the ledger if effective is set - then the last transaction date prior to this """ opening = self.openingDate() # hmmm - still worried about missing/incomplete catalogs for brain in self.bastionLedger().evalAdvancedQuery(Eq('accountId', self.getId(), filter=True) & \ Eq('ledgerId', self.aq_parent.getId(), filter=True) & \ In('meta_type', ('BLEntry', 'BLSubsidiaryEntry')) & \ Ge('effective', opening, filter=True), (('effective', 'desc'),)): try: #return brain._unrestrictedGetObject().effective() entry = brain._unrestrictedGetObject() #print "%s - lastTransactionDate() entry=%s [effective=%s]" % (self.getId(), repr(entry), entry.effective()) return entry.effective() except: pass return opening AccessControl.class_init.InitializeClass(BLAccount) # deprecated implementation class BLAccounts(BTreeFolder2, ZCatalog, PropertyManager): pass def accno_field_cmp(x, y): if x.accno == y.accno: return 0 if x.accno > y.accno: return 1 return -1 def date_field_cmp(x, y): # we could have dangling accounts :( try: x_dt = x[1].effective() except: x_dt = EPOCH try: y_dt = y[1].effective() except: y_dt = EPOCH if x_dt == y_dt: return 0 if x_dt > y_dt: return 1 return -1 def accountAdd(ob, event): LOG.info('Adding %s' % ob.getId()) # controller tool Ledger doesn't have indexing try: ob.indexObject() except AttributeError: pass # allow copy/pasted to retain balance info if getattr(aq_base(ob), '_balance', None): return # set up cache-amount/totalise stuff in the ledger's default currency ob._balance = ob.aq_parent.zeroAmount() ob._balance_dt = EPOCH