__all__ = ["Account"]
def _account_root():
return "accounting/accounts"
def _get_last_day(datetime):
"""Return the start of the day before 'datetime', e.g.
_get_last_day(April 1st) will return March 31st
"""
import datetime as _datetime
datetime = datetime - _datetime.timedelta(days=1)
return _datetime.datetime(year=datetime.year, month=datetime.month,
day=datetime.day, tzinfo=datetime.tzinfo)
def _get_last_month(datetime):
"""Return the date at the start of the month before 'datetime', e.g.
_get_last_month(March 21st) will return February 1st
"""
import datetime as _datetime
datetime = datetime.replace(day=1) - _datetime.timedelta(days=1)
return _datetime.datetime(year=datetime.year, month=datetime.month,
day=1, tzinfo=datetime.tzinfo)
def _get_hourly_datetime(datetime):
"""Return the datetime for the top of the hour of 'datetime',
e.g. 5.42pm would return 5.00pm
"""
from Acquire.ObjectStore import datetime_to_datetime \
as _datetime_to_datetime
import datetime as _datetime
datetime = _datetime_to_datetime(datetime)
return _datetime.datetime(year=datetime.year,
month=datetime.month,
day=datetime.day,
hour=datetime.hour,
tzinfo=datetime.tzinfo)
def _get_key_from_hour(start, datetime):
"""Return a key encoding the passed date, starting the key with 'start',
but only up unto the specified hour
"""
from Acquire.ObjectStore import datetime_to_datetime \
as _datetime_to_datetime
datetime = _datetime_to_datetime(datetime)
return "%s/%sT%02d" % (start, datetime.date().isoformat(), datetime.hour)
def _get_key_from_day(start, datetime):
"""Return a key encoding the passed date, starting the key with 'start',
but only up until the specified day
"""
from Acquire.ObjectStore import datetime_to_datetime \
as _datetime_to_datetime
datetime = _datetime_to_datetime(datetime)
return "%s/%4d-%02d-%02d" % (start, datetime.year, datetime.month,
datetime.day)
def _get_key_from_month(start, datetime):
"""Return a key encoding the passed date, starting the key with 'start',
but only up to the specified month
"""
from Acquire.ObjectStore import datetime_to_datetime \
as _datetime_to_datetime
datetime = _datetime_to_datetime(datetime)
return "%s/%4d-%02d" % (start, datetime.year, datetime.month)
def _get_hour_from_key(key):
"""Return the date that is encoded in the passed key"""
import re as _re
m = _re.search(r"(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)", key)
if m:
from Acquire.ObjectStore import date_and_time_to_datetime \
as _date_and_time_to_datetime
import datetime as _datetime
return _date_and_time_to_datetime(
_datetime.date(year=int(m.groups()[0]),
month=int(m.groups()[1]),
day=int(m.groups()[2])),
_datetime.time(hour=int(m.groups()[3])))
else:
from Acquire.Accounting import AccountError
raise AccountError("Could not find a date in the key '%s'" % key)
def _get_datetime_from_key(key):
"""Return the datetime that is encoded in the passed key
Args:
key(st: obj: `str`): Key to search for datetime
Returns:
datetime: detailed datetime object read from key
"""
import re as _re
m = _re.search(r"(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\.(\d+)",
key)
if m:
from Acquire.ObjectStore import date_and_time_to_datetime \
as _datetime_to_datetime
import datetime as _datetime
return _datetime_to_datetime(
_datetime.datetime(year=int(m.groups()[0]),
month=int(m.groups()[1]),
day=int(m.groups()[2]),
hour=int(m.groups()[3]),
minute=int(m.groups()[4]),
second=int(m.groups()[5]),
microsecond=int(m.groups()[6])))
else:
from Acquire.Accounting import AccountError
raise AccountError("Could not find a datetime in the key '%s'" % key)
def _sum_transactions(transactions):
"""Internal function that sums all of the transactions identified
by the passed keys. by the passed keys. This returns a tuple of
(balance, liability, receivable, spent)
Args:
keys (:obj:`list`): List of keys to parse
Returns:
tuple (:obj:`Decimal`, Decimal, Decimal, Decimal): balance, liability,
receivable, spent_today
"""
from Acquire.Accounting import Balance as _Balance
from Acquire.Accounting import TransactionInfo as _TransactionInfo
balance = _Balance()
for transaction in transactions:
if not isinstance(transaction, _TransactionInfo):
transaction = _TransactionInfo(transaction)
balance = balance + transaction
return balance
[docs]class Account:
"""This class represents a single account in the ledger. It has a balance,
and a record of the set of transactions that have been applied.
The account really holds two accounts: the liability account and
actual capital account. We combine both together into a single
account to ensure that updates occur atomically
All data for this account is stored in the object store
The account has a set of ACLRules that specify who can
read and write to the account (writing implies has spend
authority), and who owns the account (can change ACLRules)
"""
def __init__(self, name=None, description=None, uid=None,
aclrules=None, group_name=None, bucket=None):
"""Construct the account. If 'uid' is specified, then load the account
from the object store (so 'name' and 'description' should be "None")
You can also supply the ACLRules that will be used to control
access to this account. If these are not specified then
ACLRules.inherit() will be used, with rules inherited from the
Accounts group that contains this Account
Args:
name (:obj:`str`, default=None): Name on the account
description (:obj:`str`, default=None): Description of account
uid (UID): Unique ID for account, if used do not pass name or
description
bucket (dict): contains data for bucket
Returns:
None
"""
self._name = None
self._description = None
self._last_update = {}
self._uid = None
self._group_name = None
if uid is not None:
self._uid = str(uid)
bucket = self._get_account_bucket(bucket)
self._load_account(bucket)
if name:
if name != self.name():
from Acquire.Accounting import AccountError
raise AccountError(
"This account name '%s' does not match what you "
"expect! '%s'" % (self.name(), name))
if description:
if description != self.description():
from Acquire.Accounting import AccountError
raise AccountError(
"This account description '%s' does not match what "
"you expect! '%s'" % (self.description(), description))
elif name is not None:
self._uid = None
self._create_account(name=name, description=description,
group_name=group_name, aclrules=aclrules,
bucket=bucket)
[docs] def is_null(self):
"""Return whether or not this is a null account"""
return self._uid is None
def _get_now(self, now=None):
"""Return the time of 'now' (or actual now if not passed)"""
if now is None:
from Acquire.ObjectStore import get_datetime_now \
as _get_datetime_now
return _get_datetime_now()
else:
from Acquire.ObjectStore import datetime_to_datetime \
as _datetime_to_datetime
return _datetime_to_datetime(now)
def _get_account_bucket(self, bucket=None):
"""Return the bucket into which to write this account,
or 'bucket' if it is not None
"""
if bucket is None:
from Acquire.Service import get_service_account_bucket \
as _get_service_account_bucket
return _get_service_account_bucket()
else:
return bucket
def __str__(self):
if self._uid is None:
return "Account::null"
else:
return "Account(%s|%s|%s)" % (self._name, self._description,
self._uid)
def __eq__(self, other):
if isinstance(other, self.__class__):
return self._uid == other._uid
else:
return False
def __ne__(self, other):
return not self.__eq__(other)
def _create_account(self, name, description, group_name, aclrules,
bucket=None):
"""Create the account from scratch"""
if name is None or description is None:
from Acquire.Accounting import AccountError
raise AccountError(
"You must pass both a name and description to create a new "
"account")
if self._uid is not None:
from Acquire.Accounting import AccountError
raise AccountError("You cannot create an account twice!")
from Acquire.Identity import ACLRules as _ACLRules
if aclrules is None:
aclrules = _ACLRules.inherit()
elif not isinstance(aclrules, _ACLRules):
raise TypeError("The aclrules must be type ACLRules")
from Acquire.Accounting import create_decimal as _create_decimal
from Acquire.ObjectStore import create_uuid as _create_uuid
bucket = self._get_account_bucket(bucket)
self._uid = _create_uuid()
self._name = str(name)
self._description = str(description)
self._overdraft_limit = _create_decimal(0)
self._maximum_daily_limit = 0
self._aclrules = aclrules
if group_name is None:
self._group_name = None
else:
self._group_name = str(group_name)
# make sure that this is saved to the object store
self._save_account(bucket)
def _get_transactions_between(self, start_datetime, end_datetime,
bucket=None):
"""Return all of the object store keys for transactions in this
account beteen 'start_datetime' and 'end_datetime' (inclusive, e.g.
start_datetime < transaction <= end_datetime). This will return an
empty list if there were no transactions in this time
"""
# convert both times to UTC
from Acquire.ObjectStore import datetime_to_datetime \
as _datetime_to_datetime
import datetime as _datetime
if start_datetime is None or end_datetime is None:
raise ValueError("NULL %s | %s" % (start_datetime, end_datetime))
start_datetime = _datetime_to_datetime(start_datetime)
end_datetime = _datetime_to_datetime(end_datetime)
# get the day for each time
start_day = start_datetime.toordinal()
end_day = end_datetime.toordinal()
if end_datetime.time() == _datetime.time():
# this ends on midnight of the first day - do not
# include this last day as nothing will match
end_day -= 1
from Acquire.ObjectStore import string_to_datetime \
as _string_to_datetime
from Acquire.ObjectStore import date_to_string as _date_to_string
from Acquire.ObjectStore import ObjectStore as _ObjectStore
from Acquire.Accounting import TransactionInfo as _TransactionInfo
bucket = self._get_account_bucket()
num_days = end_day - start_day
if num_days < 7:
# sufficiently few days that a day-by-day search is enough
transactions = []
for day in range(start_day, end_day+1):
day_date = _datetime.datetime.fromordinal(day)
day_string = _date_to_string(day_date)
prefix = "%s/%s" % (self._transactions_key(), day_string)
try:
keys = _ObjectStore.get_all_object_names(bucket=bucket,
prefix=prefix)
except:
keys = []
for key in keys:
transaction = _TransactionInfo.from_key(key)
datetime = transaction.datetime()
if datetime > start_datetime and datetime <= end_datetime:
transactions.append(transaction)
return transactions
# elif num_days < 300: Try a better algorithm for weeks and months
else:
# likely more than years - easier to just scan all transactions
# on the account
prefix = self._transactions_key()
try:
keys = _ObjectStore.get_all_object_names(bucket=bucket,
prefix=prefix)
except:
keys = []
transactions = []
for key in keys:
transaction = _TransactionInfo.from_key(key)
datetime = transaction.datetime()
if datetime > start_datetime and datetime <= end_datetime:
transactions.append(transaction)
return transactions
def _get_balance_key(self, now=None):
"""Return the balance key for the passed time. This is the key
into the object store of the object that holds the starting
balance for the account on the hour of the passed datetime.
If 'now' is None, then the key for actual now is returned
"""
if self.is_null():
return None
else:
return _get_key_from_hour(start=self._balance_key(),
datetime=self._get_now(now))
def _find_last_balance_key(self, now=None, bucket=None):
"""Return the key containing the last hourly balance update before
'now' (defaults to actual now if not set)
"""
from Acquire.ObjectStore import ObjectStore as _ObjectStore
now = self._get_now(now)
bucket = self._get_account_bucket(bucket)
start = self._balance_key()
# look for any balance keys from today
prefix = _get_key_from_day(start=start, datetime=now)
try:
keys = _ObjectStore.get_all_object_names(bucket=bucket,
prefix=prefix)
except:
keys = []
if len(keys) > 0:
keys.sort()
return keys[-1]
# look for any balance keys from yesterday
# (we do this to stop big lookups for active accounts when
# we jump between months)
now = _get_last_day(now)
prefix = _get_key_from_day(start=start, datetime=now)
try:
keys = _ObjectStore.get_all_object_names(bucket=bucket,
prefix=prefix)
except:
keys = []
if len(keys) > 0:
keys.sort()
return keys[-1]
# look for any balance keys from this month
prefix = _get_key_from_month(start=start, datetime=now)
try:
keys = _ObjectStore.get_all_object_names(bucket=bucket,
prefix=prefix)
except:
keys = []
if len(keys) > 0:
keys.sort()
return keys[-1]
for _ in range(0, 6):
now = _get_last_month(now)
prefix = _get_key_from_month(start=start, datetime=now)
try:
keys = _ObjectStore.get_all_object_names(bucket=bucket,
prefix=prefix)
except:
keys = []
if len(keys) > 0:
keys.sort()
return keys[-1]
# wow - no balance keys at all over the last 6 months. Look for
# *any* balance keys
try:
keys = _ObjectStore.get_all_object_names(bucket=bucket,
prefix=prefix)
except:
keys = []
if len(keys) > 0:
keys.sort()
# can only return the latest key before 'now'
for i in range(len(keys)-1, 0, -1):
key = keys[i]
hourly_time = _get_hour_from_key(key)
if hourly_time < now:
return key
# no balance keys at all! Set a balance key for the beginning of time
from Acquire.ObjectStore import datetime_to_datetime \
as _datetime_to_datetime
from Acquire.Accounting import Balance as _Balance
import datetime as _datetime
hourly_time = _datetime_to_datetime(_datetime.datetime.fromordinal(1))
hourly_key = self._get_balance_key(now=hourly_time)
hourly_balance = _Balance()
_ObjectStore.set_object_from_json(bucket=bucket, key=hourly_key,
data=hourly_balance.to_data())
return hourly_key
def _get_hourly_balance(self, now=None, bucket=None):
"""Calculate and return the balance at the top of the hour
for 'now' (defaults to actually now if not specified)
"""
now = self._get_now(now)
hourly_key = self._get_balance_key(now)
if hourly_key in self._last_update:
return self._last_update[hourly_key]["hourly_balance"]
from Acquire.Accounting import Balance as _Balance
from Acquire.ObjectStore import ObjectStore as _ObjectStore
bucket = self._get_account_bucket(bucket)
try:
data = _ObjectStore.get_object_from_json(bucket=bucket,
key=hourly_key)
hourly_balance = _Balance.from_data(data)
except:
hourly_balance = None
hourly_now_time = _get_hourly_datetime(now)
if hourly_balance is None:
# look for the last balance key...
last_balance_key = self._find_last_balance_key(now=now,
bucket=bucket)
if last_balance_key is None:
from Acquire.Accounting import AccountError
raise AccountError(
"The first balance of the account %s has not been set?"
% str(self))
data = _ObjectStore.get_object_from_json(bucket=bucket,
key=last_balance_key)
last_balance = _Balance.from_data(data)
last_balance_time = _get_hour_from_key(last_balance_key)
transactions = self._get_transactions_between(
start_datetime=last_balance_time,
end_datetime=hourly_now_time)
total = _sum_transactions(transactions)
hourly_balance = last_balance + total
_ObjectStore.set_object_from_json(bucket=bucket,
key=hourly_key,
data=hourly_balance.to_data())
self._last_update[hourly_key] = \
{"hourly_balance": hourly_balance,
"last_update_time": hourly_now_time,
"last_update_balance": hourly_balance}
return hourly_balance
[docs] def balance(self, now=None, bucket=None):
"""Get the balance of the account at 'now' (defaults to actually now).
This returns a Balance object for the balance, that includes
(1) the current real balance of the account,
neglecting any outstanding liabilities or accounts receivable,
(2) the current total liabilities,
and (3) the current total accounts receivable
where 'liability' is the current total liabilities,
where 'receivable' is the current total accounts receivable, and
where 'spent_today' is how much has been spent today (from midnight
until now)
Args:
bucket (dict, default=None): Bucket to use for calculations
Returns:
tuple (Decimal, Decimal, Decimal, Decimal): balance, liability,
receivable, spent_today
"""
now = self._get_now(now)
bucket = self._get_account_bucket(bucket)
# get the key to the hourly balance for now
hourly_key = self._get_balance_key(now)
try:
hourly_update = self._last_update[hourly_key]
except:
hourly_update = None
from Acquire.ObjectStore import ObjectStore as _ObjectStore
bucket = self._get_account_bucket()
if hourly_update is None:
hourly_balance = self._get_hourly_balance(bucket=bucket,
now=now)
last_update_time = _get_hourly_datetime(now)
last_update_balance = hourly_balance
else:
hourly_balance = hourly_update["hourly_balance"]
last_update_time = hourly_update["last_update_time"]
last_update_balance = hourly_update["last_update_balance"]
if last_update_time >= now:
# the last update of this balance was in the future - go from
# the current hour
last_update_time = _get_hourly_datetime(now)
last_update_balance = hourly_balance
# next, get the transactions that have taken place since the last
# update and sum them to get the current balance
transactions = self._get_transactions_between(
start_datetime=last_update_time,
end_datetime=now, bucket=bucket)
total = last_update_balance + _sum_transactions(transactions)
self._last_update[hourly_key] = {"hourly_balance": hourly_balance,
"last_update_time": now,
"last_update_balance": total}
return total
[docs] def name(self):
"""Return the name of this account
Returns:
str or None: Name of account if account not null, else None
"""
if self.is_null():
return None
return self._name
[docs] def group_name(self):
"""Return the name of the Accounts group in which this
account belongs. An Account can only exist in a single
Accounts Group at a time
"""
if self.is_null():
return None
return self._group_name
[docs] def description(self):
"""Return the description of this account
Returns:
str or None: Description of account if account not null,
else None
"""
if self.is_null():
return None
return self._description
[docs] def uid(self):
"""Return the UID for this account.
Returns:
str: UID
"""
return self._uid
[docs] def assert_valid_authorisation(self, authorisation, resource=None,
accept_partial_match=False):
"""Assert that the passed authorisation is valid for this
account
Args:
authorisation (Authorisation): authorisation
object to be used for account
Returns:
None
"""
if authorisation is None:
raise PermissionError("You need to supply a valid authorisation!")
from Acquire.Identity import Authorisation as _Authorisation
if not isinstance(authorisation, _Authorisation):
raise TypeError("The passed authorisation must be an "
"Authorisation")
user_guid = None
identifiers = None
if not authorisation.is_null():
identifiers = authorisation.verify(
resource=resource,
accept_partial_match=accept_partial_match,
return_identifiers=True)
user_guid = authorisation.user_guid()
upstream = None
if self.group_name() is not None:
from Acquire.Accounting import Accounts as _Accounts
group = _Accounts(user_guid=user_guid, group=self.group_name())
upstream = group.aclrules().resolve(must_resolve=False,
identifiers=identifiers)
aclrule = self._aclrules.resolve(must_resolve=True,
upstream=upstream,
identifiers=identifiers)
if not aclrule.is_writeable():
raise PermissionError(
"You do not have permission to write (draw funds) from "
"this account!")
def _get_safe_now(self):
"""This function returns the current time. It avoids dangerous
times (when the system may be updating) by sleeping through
those times (i.e. it will sleep from HH:59:58 until HH+1:00:01)
"""
now = self._get_now()
# don't allow any transactions in the last 2 seconds of the hour, as
# we will sum up the day balance at the top of each hour, and
# don't want to risk any late transactions from messing up the
# accounting. We also don't want any transactions at exactly the
# top of the hour in case they get missed out when getting
# transactions with date ranges
while (now.minute == 59 and now.second >= 58) or \
(now.minute == 0 and now.second == 0 and now.microsecond < 10):
import time as _time
# sleep in quarter-second increments to minimise disruption
_time.sleep(0.25)
new_now = self._get_now()
if new_now == now:
# now time has passed - we are being tested, so let's
# pretend that time has gone forwards
import datetime as _datetime
new_now = now + _datetime.timedelta(seconds=1)
now = new_now
return now
def _credit_refund(self, debit_note, refund, bucket=None):
"""Credit the value of the passed 'refund' to this account. The
refund must be for a previous completed debit, hence the
original debitted value is returned to the account.
Args:
debit_note (DebitNote): Note to be used for
refund
refund (Refund): Refund holding value to be refunded
bucket (dict, default=None): Bucket to load data from
Returns:
tuple (str, datetime): Return the UID and current time
"""
from Acquire.Accounting import Refund as _Refund
from Acquire.Accounting import DebitNote as _DebitNote
if not isinstance(refund, _Refund):
raise TypeError("The passed refund must be a Refund")
if not isinstance(debit_note, _DebitNote):
raise TypeError("The passed debit note must be a DebitNote")
if refund.is_null():
return
if refund.value() != debit_note.value():
raise ValueError("The refunded value does not match the value "
"of the debit note: %s versus %s" %
(refund.value(), debit_note.value()))
from Acquire.Accounting import TransactionInfo as _TransactionInfo
from Acquire.Accounting import TransactionCode as _TransactionCode
encoded_value = _TransactionInfo.encode(
_TransactionCode.RECEIVED_REFUND,
refund.value())
# create a UID and datetime for this credit and record
# it in the account
now = self._get_safe_now()
# and to create a key to find this credit later. The key is made
# up from the iso format of the datetime of the credit
# and a random string
from Acquire.ObjectStore import datetime_to_string \
as _datetime_to_string
from Acquire.ObjectStore import ObjectStore as _ObjectStore
from Acquire.Accounting import LineItem as _LineItem
from Acquire.ObjectStore import create_uuid as _create_uuid
datetime_key = _datetime_to_string(now)
uid = "%s/%s" % (datetime_key, _create_uuid()[0:8])
item_key = "%s/%s/%s" % (self._transactions_key(), uid, encoded_value)
l = _LineItem(debit_note.uid(), refund.authorisation())
bucket = self._get_account_bucket()
_ObjectStore.set_object_from_json(bucket, item_key, l.to_data())
return (uid, now)
def _debit_refund(self, refund, bucket=None):
"""Debit the value of the passed 'refund' from this account. The
refund must be for a previous completed credit. There is a risk
that this value has been spent, so this is one of the only
functions that allows a balance to drop below an overdraft or
other limit (as the refund should always succeed).
Args:
refund (Refund): Refund note to be processed
bucket (dict, default=None): Bucket to load data from
Returns:
tuple (str, datetime): UID and current time
"""
from Acquire.Accounting import Refund as _Refund
if not isinstance(refund, _Refund):
raise TypeError("The passed refund must be a Refund")
if refund.is_null():
return
from Acquire.Accounting import TransactionInfo as _TransactionInfo
from Acquire.Accounting import TransactionCode as _TransactionCode
encoded_value = _TransactionInfo.encode(_TransactionCode.SENT_REFUND,
refund.value())
bucket = self._get_account_bucket()
while True:
# create a UID and datetime for this debit and record
# it in the account
now = self._get_safe_now()
# and to create a key to find this debit later. The key is made
# up from the date and of the debit and a random string
from Acquire.ObjectStore import datetime_to_string \
as _datetime_to_string
from Acquire.ObjectStore import ObjectStore as _ObjectStore
from Acquire.Accounting import LineItem as _LineItem
from Acquire.ObjectStore import create_uuid as _create_uuid
datetime_key = _datetime_to_string(now)
uid = "%s/%s" % (datetime_key, _create_uuid()[0:8])
item_key = "%s/%s/%s" % (self._transactions_key(),
uid, encoded_value)
l = _LineItem(uid, refund.authorisation())
now2 = self._get_safe_now()
if now2.hour == now.hour:
# we have not moved into the next hour
break
_ObjectStore.set_object_from_json(bucket, item_key, l.to_data())
return (uid, now)
def _credit_receipt(self, debit_note, receipt, bucket=None):
"""Credit the value of the passed 'receipt' to this account. The
receipt must be for a previous provisional credit, hence the
money is awaiting transfer from accounts receivable.
Args:
debit_note (DebitNote): Holds the value of the credit
to be applied to the account, value must match that of receipt
receipt (Receipt): Receipt holding the value of the credit
that is to be applied to account
TODO - improve bucket docs
bucket (dict, default=None): Bucket to load data from
Returns:
tuple (str, datetime): UID and current time
"""
from Acquire.Accounting import Receipt as _Receipt
from Acquire.Accounting import DebitNote as _DebitNote
if not isinstance(receipt, _Receipt):
raise TypeError("The passed receipt must be a Receipt")
if not isinstance(debit_note, _DebitNote):
raise TypeError("The passed debit note must be a DebitNote")
if receipt.is_null():
return
if receipt.receipted_value() != debit_note.value():
raise ValueError("The receipted value does not match the value "
"of the debit note: %s versus %s" %
(receipt.receipted_value(), debit_note.value()))
from Acquire.Accounting import TransactionInfo as _TransactionInfo
from Acquire.Accounting import TransactionCode as _TransactionCode
encoded_value = _TransactionInfo.encode(
_TransactionCode.SENT_RECEIPT,
receipt.value(), receipt.receipted_value())
bucket = self._get_account_bucket()
while True:
# create a UID and datetime for this credit and record
# it in the account
now = self._get_safe_now()
# and to create a key to find this credit later. The key is made
# up from the isoformat datetime of the credit and a random string
from Acquire.ObjectStore import datetime_to_string \
as _datetime_to_string
from Acquire.ObjectStore import ObjectStore as _ObjectStore
from Acquire.Accounting import LineItem as _LineItem
from Acquire.ObjectStore import create_uuid as _create_uuid
datetime_key = _datetime_to_string(now)
uid = "%s/%s" % (datetime_key, _create_uuid()[0:8])
item_key = "%s/%s/%s" % (self._transactions_key(),
uid, encoded_value)
l = _LineItem(debit_note.uid(), receipt.authorisation())
now2 = self._get_safe_now()
if now2.hour == now.hour:
# we have not moved into another hour
break
_ObjectStore.set_object_from_json(bucket, item_key, l.to_data())
return (uid, now)
def _debit_receipt(self, receipt, bucket=None):
"""Debit the value of the passed 'receipt' from this account. The
receipt must be for a previous provisional debit, hence
the money should be available.
Args:
receipt (Receipt): holds the value
to be debited from the account
TODO - improve bucket docs
bucket (dict, default=None): Bucket to load data from
Returns:
tuple (str, datetime): UID and current time
"""
from Acquire.Accounting import Receipt as _Receipt
if not isinstance(receipt, _Receipt):
raise TypeError("The passed receipt must be a Receipt")
if receipt.is_null():
return
from Acquire.Accounting import TransactionInfo as _TransactionInfo
from Acquire.Accounting import TransactionCode as _TransactionCode
encoded_value = _TransactionInfo.encode(
_TransactionCode.RECEIVED_RECEIPT,
receipt.value(), receipt.receipted_value())
bucket = self._get_account_bucket()
# create a UID and datetime for this debit and record
# it in the account
while True:
now = self._get_safe_now()
# and to create a key to find this debit later. The key is made
# up from the isoformat datetime of the debit and a random string
from Acquire.ObjectStore import datetime_to_string \
as _datetime_to_string
from Acquire.ObjectStore import ObjectStore as _ObjectStore
from Acquire.Accounting import LineItem as _LineItem
from Acquire.ObjectStore import create_uuid as _create_uuid
datetime_key = _datetime_to_string(now)
uid = "%s/%s" % (datetime_key, _create_uuid()[0:8])
item_key = "%s/%s/%s" % (self._transactions_key(),
uid, encoded_value)
l = _LineItem(uid, receipt.authorisation())
now2 = self._get_safe_now()
if now2.hour == now.hour:
# we are safely in the same hour
break
_ObjectStore.set_object_from_json(bucket, item_key, l.to_data())
return (uid, now)
def _credit(self, debit_note, bucket=None):
"""Credit the value in 'debit_note' to this account. If the debit_note
shows that the payment is provisional then this will be recorded
as accounts receivable. This will record the credit with the
same UID as the debit identified in the debit_note, so that
we can reconcile all credits against matching debits.
Args:
debit_note (DebitNote): Holds the value to be credited
to this account
TODO - improve bucket docs
bucket (dict, default=None): Bucket to load data from
Returns:
tuple (str, datetime): UID and current time
"""
from Acquire.Accounting import DebitNote as _DebitNote
if not isinstance(debit_note, _DebitNote):
raise TypeError("The passed debit note must be a DebitNote")
if debit_note.value() <= 0:
return
from Acquire.Accounting import TransactionInfo as _TransactionInfo
from Acquire.Accounting import TransactionCode as _TransactionCode
if debit_note.is_provisional():
encoded_value = _TransactionInfo.encode(
_TransactionCode.ACCOUNT_RECEIVABLE,
debit_note.value())
else:
encoded_value = _TransactionInfo.encode(
_TransactionCode.CREDIT,
debit_note.value())
bucket = self._get_account_bucket()
# create a UID and datetime for this credit and record
# it in the account
while True:
now = self._get_safe_now()
# and to create a key to find this credit later. The key is made
# up from the isoformat datetime of the credit and a random string
from Acquire.ObjectStore import datetime_to_string \
as _datetime_to_string
from Acquire.ObjectStore import ObjectStore as _ObjectStore
from Acquire.Accounting import LineItem as _LineItem
from Acquire.ObjectStore import create_uuid as _create_uuid
datetime_key = _datetime_to_string(now)
uid = "%s/%s" % (datetime_key, _create_uuid()[0:8])
item_key = "%s/%s/%s" % (self._transactions_key(),
uid, encoded_value)
now2 = self._get_safe_now()
if now2.hour == now.hour:
# we are safely in the same hour
break
# the line item records the UID of the debit note, so we can
# find this debit note in the system and, from this, get the
# original transaction in the transaction record
l = _LineItem(debit_note.uid(), debit_note.authorisation())
_ObjectStore.set_object_from_json(bucket, item_key, l.to_data())
return (uid, now)
def _debit(self, transaction, authorisation,
is_provisional, receipt_by,
authorisation_resource=None, bucket=None):
"""Debit the value of the passed transaction from this account based
on the authorisation contained
in 'authorisation'. This will create a unique ID (UID) for
this debit and will return this together with the datetime of the
debit. If this transaction 'is_provisional' then it will be
recorded as a liability, which must be receipted before
'receipt_by'. If 'receipt_by' is None, then this will
automatically be 1 week in the future
The UID will encode both the date of the debit and provide a random
ID that together can be used to identify the transaction associated
with this debit in the future.
This will raise an exception if the debit cannot be completed, e.g.
if the authorisation is invalid, if the debit exceeds a limit or
there are insufficient funds in the account
Note that this function is private as it should only be called
by the DebitNote class
Args:
transaction (Transaction): Holds the value to be debited
from this account
authorisation (Authorisation): Authorisation for the
transaction
is_provisional (bool): If True the transaction will be
recorded as a liability
receipt_by (datetime): Datetime by which the transaction
should be receipted
TODO - improve bucket docs
bucket (dict, default=None): Bucket to load data from
Returns:
tuple (str, datetime, datetime): uid, now, receipt_by
"""
if self.is_null() or transaction.value() <= 0:
return None
from Acquire.Accounting import Transaction as _Transaction
if not isinstance(transaction, _Transaction):
raise TypeError("The passed transaction must be a Transaction!")
if authorisation_resource is None:
authorisation_resource = transaction.fingerprint()
accept_partial_match = True
else:
accept_partial_match = False
self.assert_valid_authorisation(
authorisation=authorisation,
resource=authorisation_resource,
accept_partial_match=accept_partial_match)
bucket = self._get_account_bucket()
balance = self.balance(bucket=bucket)
if balance.available(self.get_overdraft_limit()) < transaction.value():
from Acquire.Accounting import InsufficientFundsError
raise InsufficientFundsError(
"You cannot debit '%s' from account %s as there "
"are insufficient funds in this account." %
(transaction, str(self)))
from Acquire.ObjectStore import datetime_to_string \
as _datetime_to_string
from Acquire.ObjectStore import datetime_to_datetime \
as _datetime_to_datetime
from Acquire.ObjectStore import get_datetime_future \
as _get_datetime_future
from Acquire.ObjectStore import ObjectStore as _ObjectStore
from Acquire.Accounting import LineItem as _LineItem
from Acquire.Accounting import TransactionInfo as _TransactionInfo
from Acquire.Accounting import TransactionCode as _TransactionCode
while True:
# create a UID and datetime for this debit and record
# it in the account
now = self._get_safe_now()
if is_provisional:
if receipt_by is None:
receipt_by = _get_datetime_future(days=7)
else:
receipt_by = _datetime_to_datetime(receipt_by)
delta = (receipt_by - now).total_seconds()
if delta < 3600:
from Acquire.Accounting import AccountError
raise AccountError(
"You cannot request a receipt to be provided less "
"than 1 hour into the future! %s versus %s is only "
"%s second(s) in the future!" %
(_datetime_to_string(receipt_by),
_datetime_to_string(now), delta))
else:
receipt_by = None
# and to create a key to find this debit later. The key is made
# up from the isoformat datetime of the debit and a random string
from Acquire.ObjectStore import create_uuid as _create_uuid
datetime_key = _datetime_to_string(now)
uid = "%s/%s" % (datetime_key, _create_uuid()[0:8])
# the key in the object store is a combination of the key for this
# account plus the uid for the debit plus the actual debit value.
# We record the debit value in the key so that we can accumulate
# the balance from just the key names
if is_provisional:
encoded_value = _TransactionInfo.encode(
_TransactionCode.CURRENT_LIABILITY,
transaction.value())
else:
encoded_value = _TransactionInfo.encode(
_TransactionCode.DEBIT,
transaction.value())
item_key = "%s/%s/%s" % (self._transactions_key(),
uid, encoded_value)
# create a line_item for this debit and save it to the object store
line_item = _LineItem(uid, authorisation)
# validate that we have not stepped into another hour...
now2 = self._get_safe_now()
if now.hour == now2.hour:
# we are still in the same hour, so it is safe to
# record the transaction
break
_ObjectStore.set_object_from_json(bucket=bucket, key=item_key,
data=line_item.to_data())
balance = self.balance(bucket=bucket)
if balance.available(overdraft_limit=self._overdraft_limit) < 0:
# This transaction has helped push the account beyond the
# overdraft limit. This can only happen if two debits
# take place at the same time - both should be refunded
from Acquire.Accounting import TransactionInfo \
as _TransactionInfo
info = _TransactionInfo.from_key(item_key)
info = _TransactionInfo.rescind(info)
line_item = _LineItem(uid=info.dated_uid(), authorisation=None)
item_key = "%s/%s" % (self._transactions_key(),
info.to_key())
_ObjectStore.set_object_from_json(bucket=bucket, key=item_key,
data=line_item.to_data())
raise InsufficientFundsError(
"You cannot debit '%s' from account %s as there "
"are insufficient funds in this account." %
(transaction, str(self)))
return (uid, now, receipt_by)
[docs] def get_overdraft_limit(self):
"""Return the overdraft limit of this account
Returns:
Decimal: Overdraft limit
"""
if self.is_null():
return 0
return self._overdraft_limit
[docs] def set_group(self, group, bucket=None):
"""Set the Accounts group to which this account belongs"""
if self.is_null():
return
from Acquire.Accounting import Accounts as _Accounts
if not isinstance(group, _Accounts):
raise TypeError("The Accounts group must be of type Accounts")
if self._group_name != group.name():
self._group_name = group.name()
self._save_account(bucket=bucket)
[docs] def set_overdraft_limit(self, limit, bucket=None):
"""Set the overdraft limit of this account to 'limit'
Args:
limit (int): Limit to set overdraft to
TODO
bucket (dict, default=None):
Returns:
None
"""
if self.is_null():
return
from Acquire.Accounting import create_decimal as _create_decimal
limit = _create_decimal(limit)
if limit < 0:
raise ValueError("You cannot set the overdraft limit to a "
"negative value! (%s)" % limit)
old_limit = self._overdraft_limit
if old_limit != limit:
self._overdraft_limit = limit
if self.is_beyond_overdraft_limit():
# restore the old limit
self._overdraft_limit = old_limit
from Acquire.Accounting import AccountError
raise AccountError("You cannot change the overdraft limit to "
"%s as this is greater than the current "
"balance!" % (limit))
else:
# save the new limit to the object store
self._save_account(bucket)
[docs] def is_beyond_overdraft_limit(self, bucket=None):
"""Return whether or not the current balance is beyond
the overdraft limit
Args:
TODO
bucket (dict, default=None):
Returns:
bool: True if over overdraft limit, else False
"""
available = self.balance(bucket=bucket).available()
return available < -(self.get_overdraft_limit())
def _key(self):
"""Return the key for this account in the object store"""
if self.is_null():
return None
else:
return "%s/%s" % (_account_root(), self.uid())
def _transactions_key(self):
"""Return the root key for the transactions for this account
in the object store
"""
if self.is_null():
return None
else:
return "%s/txns" % self._key()
def _balance_key(self):
"""Return the root key for the balances for this account
in this object store
"""
if self.is_null():
return None
else:
return "%s/balance" % self._key()
def _load_account(self, bucket=None):
"""Load the current state of the account from the object store"""
if self.is_null():
return
from Acquire.ObjectStore import ObjectStore as _ObjectStore
bucket = self._get_account_bucket()
try:
data = _ObjectStore.get_object_from_json(bucket=bucket,
key=self._key())
except:
data = None
if data is None:
from Acquire.Accounting import AccountError
raise AccountError(
"There is no account data for this account? %s" % self._key())
import copy as _copy
self.__dict__ = _copy.copy(Account.from_data(data).__dict__)
def _save_account(self, bucket=None):
"""Save this account back to the object store"""
from Acquire.ObjectStore import ObjectStore as _ObjectStore
bucket = self._get_account_bucket()
_ObjectStore.set_object_from_json(bucket=bucket,
key=self._key(),
data=self.to_data())
# reload, in case anyone saved just after us...
self._load_account(bucket=bucket)
[docs] def to_data(self):
"""Return a dictionary that can be encoded to json from this object"""
data = {}
if not self.is_null():
data["uid"] = self._uid
data["name"] = self._name
data["description"] = self._description
data["overdraft_limit"] = str(self._overdraft_limit)
data["aclrules"] = self._aclrules.to_data()
data["group_name"] = self._group_name
return data
[docs] @staticmethod
def from_data(data):
"""Construct and return an Account from the passed dictionary that has
been decoded from json
"""
account = Account()
if (data and len(data) > 0):
from Acquire.Accounting import create_decimal as _create_decimal
from Acquire.Identity import ACLRules as _ACLRules
account._uid = data["uid"]
account._name = data["name"]
account._description = data["description"]
account._overdraft_limit = _create_decimal(data["overdraft_limit"])
if "aclrules" in data:
account._aclrules = _ACLRules.from_data(data["aclrules"])
else:
account._aclrules = _ACLRules.inherit()
if "group_name" in data:
account._group_name = data["group_name"]
else:
account._group_name = None
return account