def _get_current_balance(self, bucket=None): """Get the balance of the account now (the current balance). This returns a tuple of (balance, liability, receivable, spent_today). where 'balance' is the current real balance of the account, neglecting any outstanding liabilities or 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) """ if bucket is None: bucket = _login_to_service_account() now = _datetime.datetime.now() now_ordinal = now.toordinal() try: last_update_ordinal = self._last_update_ordinal except: last_update_ordinal = None if last_update_ordinal != now_ordinal: # we are on a new day since the last update, so recalculate # the balance from scratch return self._recalculate_current_balance(bucket, now) else: # we have calculated the total before today. Get the transactions # since the last update and use these to update the daily spend # etc. return self._update_current_balance(bucket, now)
def _delete_note(self, note, bucket=None): """Internal function called to delete the passed note from the record. This is unsafe and should only be called from DebitNote.return_value or CreditNote.return_value (which themselves are only called from Ledger) """ if note is None: return if isinstance(note, _DebitNote) or isinstance(note, _CreditNote): item_key = "%s/%s" % (self._key(), note.uid()) if bucket is None: bucket = _login_to_service_account() # remove the note try: _ObjectStore.delete_object(bucket, item_key) except: pass # now remove all day-balances from the day before this note # to today. Hopefully this will prevent any ledger errors... day0 = _datetime.datetime.fromtimestamp( note.timestamp()).toordinal() - 1 day1 = _datetime.datetime.now().toordinal() for day in range(day0, day1 + 1): balance_key = self._get_balance_key( _datetime.datetime.fromordinal(day)) try: _ObjectStore.delete_object(bucket, balance_key) except: pass
def __init__(self, key=None, timeout=10, lease_time=10, bucket=None): """Create the mutex. The immediately tries to lock the mutex for key 'key' and will block until a lock is successfully obtained (or until 'timeout' seconds has been reached, and an exception is then thrown). If the key is provided, then this is the (single) global mutex. Note that this is really a lease, as the mutex will only be held for a maximum of 'lease_time' seconds. After this time the mutex will be automatically unlocked and made available to lock by others. You can renew the lease by re-locking the mutex. """ if key is None: key = "mutexes/none" else: key = "mutexes/%s" % str(key).replace(" ", "_") if bucket is None: from Acquire.Service import login_to_service_account as \ _login_to_service_account bucket = _login_to_service_account() self._bucket = bucket self._key = key self._secret = str(uuid.uuid4()) self._is_locked = 0 self.lock(timeout, lease_time)
def _record_daily_balance(self, balance, liability, receivable, datetime=None, bucket=None): """Record the starting balance for the day containing 'datetime' as 'balance' with the starting outstanding liabilities at 'liability' and starting outstanding accounts receivable at 'receivable' If 'datetime' is none, then the balance for today is set. """ if self.is_null(): return if datetime is None: datetime = _datetime.datetime.now() balance = _create_decimal(balance) liability = _create_decimal(liability) receivable = _create_decimal(receivable) balance_key = self._get_balance_key(datetime) if bucket is None: bucket = _login_to_service_account() data = { "balance": str(balance), "liability": str(liability), "receivable": str(receivable) } _ObjectStore.set_object_from_json(bucket, balance_key, data)
def _record_to_ledger(paired_notes, is_provisional=False, receipt=None, refund=None, bucket=None): """Internal function used to generate and record transaction records from the passed paired debit- and credit-note(s). This will write the transaction record(s) to the object store, and will also return the record(s). """ if receipt is not None: if not isinstance(receipt, _Receipt): raise TypeError("Receipts must be of type 'Receipt'") if refund is not None: if not isinstance(refund, _Refund): raise TypeError("Refunds must be of type 'Refund'") try: records = [] if bucket is None: bucket = _login_to_service_account() for paired_note in paired_notes: record = _TransactionRecord() record._debit_note = paired_note.debit_note() record._credit_note = paired_note.credit_note() if is_provisional: record._transaction_state = _TransactionState.PROVISIONAL else: record._transaction_state = _TransactionState.DIRECT if receipt is not None: record._receipt = receipt if refund is not None: record._refund = refund Ledger.save_transaction(record, bucket) records.append(record) if len(records) == 1: return records[0] else: return records except: # an error occuring here will break the system, which will # require manual cleaning. Mark this as broken! try: Ledger._set_truly_broken(paired_notes, bucket) except: pass raise SystemError("The ledger is in a very broken state!")
def _load_account(self, bucket=None): """Load the current state of the account from the object store""" if self.is_null(): return if bucket is None: bucket = _login_to_service_account() data = _ObjectStore.get_object_from_json(bucket, self._key()) self.__dict__ = _copy(Account.from_data(data).__dict__)
def load_test_and_set(uid, expected_state, new_state, bucket=None): """Static method to load up the Transaction record associated with the passed UID, check that the transaction state matches 'expected_state', and if it does, to update the transaction state to 'new_state'. This returns the loaded (and updated) transaction """ if bucket is None: bucket = _login_to_service_account() from ._ledger import Ledger as _Ledger try: mutex = _Mutex(uid, timeout=600, lease_time=600) except Exception as e: raise LedgerError("Cannot secure a Ledger mutex for transaction " "'%s'. Error = %s" % (uid, str(e))) try: transaction = _Ledger.load_transaction(uid, bucket) if transaction.transaction_state() != expected_state: raise TransactionError( "Cannot update the state of the transaction %s from " "%s to %s as it is not in the expected state" % (str(transaction), expected_state.value, new_state.value)) transaction._transaction_state = new_state except: mutex.unlock() raise # now need to write anything back if the state isn't changed if expected_state == new_state: return transaction # make sure we have enough time remaining on the lease to be # able to write this result back to the object store... if mutex.seconds_remaining_on_lease() < 100: try: mutex.fully_unlock() except: pass return TransactionRecord.load_test_and_set(uid, expected_state, new_state, bucket) try: _Ledger.save_transaction(transaction, bucket) except: mutex.unlock() raise return transaction
def save_transaction(record, bucket=None): """Save the passed transactionrecord to the object store""" if not isinstance(record, _TransactionRecord): raise TypeError("You can only write TransactionRecord objects " "to the ledger!") if not record.is_null(): if bucket is None: bucket = _login_to_service_account() _ObjectStore.set_object_from_json(bucket, Ledger.get_key(record.uid()), record.to_data())
def list_accounts(self, bucket=None): """Return the names of all of the accounts in this group""" if bucket is None: bucket = _login_to_service_account() keys = _ObjectStore.get_all_object_names(bucket, self._root()) accounts = [] for key in keys: accounts.append(_encoded_to_string(key)) return accounts
def load_transaction(uid, bucket=None): """Load the transactionrecord with UID=uid from the ledger""" if bucket is None: bucket = _login_to_service_account() data = _ObjectStore.get_object_from_json(bucket, Ledger.get_key(uid)) if data is None: raise LedgerError("There is no transaction recorded in the " "ledger with UID=%s (at key %s)" % (uid, Ledger.get_key(uid))) return _TransactionRecord.from_data(data)
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. """ if not isinstance(debit_note, _DebitNote): raise TypeError("The passed debit note must be a DebitNote") if debit_note.value() <= 0: return if bucket is None: bucket = _login_to_service_account() 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()) # create a UID and timestamp for this credit and record # it in the account now = self._get_safe_now() # we need to record the exact timestamp of this credit... timestamp = now.timestamp() # and to create a key to find this credit later. The key is made # up from the date and timestamp of the credit and a random string day_key = "%4d-%02d-%02d/%s" % (now.year, now.month, now.day, timestamp) uid = "%s/%s" % (day_key, str(_uuid.uuid4())[0:8]) item_key = "%s/%s/%s" % (self._key(), uid, encoded_value) # 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, timestamp)
def contains(self, account, bucket=None): """Return whether or not this group contains the passed account""" if not isinstance(account, _Account): raise TypeError("The passed account must be of type Account") if bucket is None: bucket = _login_to_service_account() # read the UID of the account in this group that matches the # passed account's name try: account_uid = _ObjectStore.get_string_object( bucket, self._account_key(account.name())) except: account_uid = None return account.uid() == account_uid
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. """ 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 bucket is None: bucket = _login_to_service_account() 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())) encoded_value = _TransactionInfo.encode(_TransactionCode.SENT_RECEIPT, receipt.value(), receipt.receipted_value()) # create a UID and timestamp for this credit and record # it in the account now = self._get_safe_now() # we need to record the exact timestamp of this credit... timestamp = now.timestamp() # and to create a key to find this credit later. The key is made # up from the date and timestamp of the credit and a random string day_key = "%4d-%02d-%02d/%s" % (now.year, now.month, now.day, timestamp) uid = "%s/%s" % (day_key, str(_uuid.uuid4())[0:8]) item_key = "%s/%s/%s" % (self._key(), uid, encoded_value) l = _LineItem(debit_note.uid(), receipt.authorisation()) _ObjectStore.set_object_from_json(bucket, item_key, l.to_data()) return (uid, timestamp)
def _get_daily_balance(self, bucket=None, datetime=None): """Get the daily starting balance for the passed datetime. This returns a tuple of (balance, liability, receivable). where 'balance' is the current real balance of the account, neglecting any outstanding liabilities or accounts receivable, where 'liability' is the current total liabilities, where 'receivable' is the current total accounts receivable, and If datetime is None then todays daily balance is returned. The daily balance is the balance at the start of the day. The actual balance at a particular time will be this starting balance plus/minus all of the transactions between the start of that day and the specified datetime """ if self.is_null(): return if bucket is None: bucket = _login_to_service_account() if datetime is None: datetime = _datetime.datetime.now() balance_key = self._get_balance_key(datetime) data = _ObjectStore.get_object_from_json(bucket, balance_key) if data is None: # there is no balance for this day. This means that we haven'y # yet calculated that day's balance. Do the accounting necessary # to construct that day's starting balance self._reconcile_daily_accounts(bucket) data = _ObjectStore.get_object_from_json(bucket, balance_key) if data is None: raise AccountError("The daily balance for account at date %s " "is not available" % str(datetime)) return (_create_decimal(data["balance"]), _create_decimal(data["liability"]), _create_decimal(data["receivable"]))
def _get_transaction_keys_between(self, start_time, end_time, bucket=None): """Return all of the object store keys for transactions in this account beteen 'start_time' and 'end_time' (inclusive, e.g. start_time <= transaction <= end_time). This will return an empty list if there were no transactions in this time """ if bucket is None: bucket = _login_to_service_account() if not isinstance(start_time, _datetime.datetime): raise TypeError("The start time must be a datetime object, " "not a %s" % start_time.__class__) if not isinstance(end_time, _datetime.datetime): raise TypeError("The end time must be a datetime object, " "not a %s" % end_time.__class__) start_day = start_time.toordinal() end_day = end_time.toordinal() start_timestamp = start_time.timestamp() end_timestamp = end_time.timestamp() keys = [] for day in range(start_day, end_day + 1): day_date = _datetime.datetime.fromordinal(day) prefix = "%s/%4d-%02d-%02d" % (self._key(), day_date.year, day_date.month, day_date.day) day_keys = _ObjectStore.get_all_object_names(bucket, prefix) for day_key in day_keys: try: timestamp = float(day_key.split("/")[0]) except: timestamp = 0 if timestamp >= start_timestamp and timestamp <= end_timestamp: keys.append("%s/%s" % (prefix, day_key)) return keys
def _create_account(self, name, description): """Create the account from scratch""" if name is None or description is None: raise AccountError( "You must pass both a name and description to create a new " "account") if self._uid is not None: raise AccountError("You cannot create an account twice!") self._uid = str(_uuid.uuid4()) self._name = str(name) self._description = str(description) self._overdraft_limit = _create_decimal(0) self._maximum_daily_limit = 0 self._last_update_ordinal = None # initialise the account with a balance of zero bucket = _login_to_service_account() self._record_daily_balance(0, 0, 0, bucket=bucket) # make sure that this is saved to the object store self._save_account(bucket)
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). """ if not isinstance(refund, _Refund): raise TypeError("The passed refund must be a Refund") if refund.is_null(): return if bucket is None: bucket = _login_to_service_account() encoded_value = _TransactionInfo.encode(_TransactionCode.SENT_REFUND, refund.value()) # create a UID and timestamp for this debit and record # it in the account now = self._get_safe_now() # we need to record the exact timestamp of this credit... timestamp = now.timestamp() # and to create a key to find this debit later. The key is made # up from the date and timestamp of the debit and a random string day_key = "%4d-%02d-%02d/%s" % (now.year, now.month, now.day, timestamp) uid = "%s/%s" % (day_key, str(_uuid.uuid4())[0:8]) item_key = "%s/%s/%s" % (self._key(), uid, encoded_value) l = _LineItem(uid, refund.authorisation()) _ObjectStore.set_object_from_json(bucket, item_key, l.to_data()) return (uid, timestamp)
def get_account(self, name, bucket=None): """Return the account called 'name' from this group""" if bucket is None: bucket = _login_to_service_account() try: account_uid = _ObjectStore.get_string_object( bucket, self._account_key(name)) except: account_uid = None if account_uid is None: # ensure that the user always has a "main" account if name == "main": return self.create_account("main", "primary user account", overdraft_limit=0, bucket=bucket) raise AccountError("There is no account called '%s' in the " "group '%s'" % (name, self.group())) return _Account(uid=account_uid, bucket=bucket)
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. """ if not isinstance(receipt, _Receipt): raise TypeError("The passed receipt must be a Receipt") if receipt.is_null(): return if bucket is None: bucket = _login_to_service_account() encoded_value = _TransactionInfo.encode( _TransactionCode.RECEIVED_RECEIPT, receipt.value(), receipt.receipted_value()) # create a UID and timestamp for this debit and record # it in the account now = self._get_safe_now() # we need to record the exact timestamp of this credit... timestamp = now.timestamp() # and to create a key to find this debit later. The key is made # up from the date and timestamp of the debit and a random string day_key = "%4d-%02d-%02d/%s" % (now.year, now.month, now.day, timestamp) uid = "%s/%s" % (day_key, str(_uuid.uuid4())[0:8]) item_key = "%s/%s/%s" % (self._key(), uid, encoded_value) l = _LineItem(uid, receipt.authorisation()) _ObjectStore.set_object_from_json(bucket, item_key, l.to_data()) return (uid, timestamp)
def _create_from_receipt(self, receipt, account, bucket): """Function used to construct a debit note by extracting the value specified in the passed receipt from the specified account. This is authorised using the authorisation held in the receipt, based on the original authorisation given in the provisional transaction. Note that the receipt must match up with a prior existing provisional transaction, and this must not have already been receipted or refunded. This will actually take value out of the passed account, with that value residing in this debit note until it is credited to another account """ from ._receipt import Receipt as _Receipt if not isinstance(receipt, _Receipt): raise TypeError("You can only create a DebitNote with a " "Receipt") if receipt.is_null(): return if bucket is None: bucket = _login_to_service_account() from ._transactionrecord import TransactionRecord as _TransactionRecord from ._transactionrecord import TransactionState as _TransactionState from ._account import Account as _Account # get the transaction behind this receipt and move it into # the "receipting" state transaction = _TransactionRecord.load_test_and_set( receipt.transaction_uid(), _TransactionState.PROVISIONAL, _TransactionState.RECEIPTING, bucket=bucket) try: # ensure that the receipt matches the transaction... transaction.assert_matching_receipt(receipt) if account is None: account = _Account(transaction.debit_account_uid(), bucket) elif account.uid() != receipt.debit_account_uid(): raise ValueError("The accounts do not match when debiting " "the receipt: %s versus %s" % (account.uid(), receipt.debit_account_uid())) # now move value from liability to debit, and then into this # debit note (uid, timestamp) = account._debit_receipt(receipt, bucket) self._transaction = receipt.transaction() self._account_uid = receipt.debit_account_uid() self._authorisation = receipt.authorisation() self._is_provisional = False self._timestamp = float(timestamp) self._uid = str(uid) except: # move the transaction back to its original state... _TransactionRecord.load_test_and_set(receipt.transaction_uid(), _TransactionState.RECEIPTING, _TransactionState.PROVISIONAL) raise
def perform(transactions, debit_account, credit_account, authorisation, is_provisional=False, bucket=None): """Perform the passed transaction(s) between 'debit_account' and 'credit_account', recording the 'authorisation' for this transaction. If 'is_provisional' then record this as a provisional transaction (liability for the debit_account, future unspendable income for the 'credit_account'). Payment won't actually be taken until the transaction is 'receipted' (which may be for less than (but not more than) then provisional value. Returns the (already recorded) TransactionRecord. Note that if several transactions are passed, then they must all succeed. If one of them fails then they are immediately refunded. """ if not isinstance(debit_account, _Account): raise TypeError("The Debit Account must be of type Account") if not isinstance(credit_account, _Account): raise TypeError("The Credit Account must be of type Account") if not isinstance(authorisation, _Authorisation): raise TypeError("The Authorisation must be of type Authorisation") if is_provisional: is_provisional = True else: is_provisional = False try: transactions[0] except: transactions = [transactions] # remove any zero transactions, as they are not worth recording t = [] for transaction in transactions: if not isinstance(transaction, _Transaction): raise TypeError("The Transaction must be of type Transaction") if transaction.value() >= 0: t.append(transaction) transactions = t if bucket is None: bucket = _login_to_service_account() # first, try to debit all of the transactions. If any fail (e.g. # because there is insufficient balance) then they are all # immediately refunded debit_notes = [] try: for transaction in transactions: debit_notes.append( _DebitNote(transaction, debit_account, authorisation, is_provisional, bucket=bucket)) except Exception as e: # refund all of the completed debits credit_notes = [] debit_error = str(e) try: for debit_note in debit_notes: debit_account._delete_note(debit_note, bucket=bucket) except Exception as e: raise UnbalancedLedgerError( "We have an unbalanced ledger as it was not " "possible to refund a multi-part refused credit (%s): " "Credit refusal error = %s. Refund error = %s" % (str(debit_note), str(debit_error), str(e))) # raise the original error to show that, e.g. there was # insufficient balance raise e # now create the credit note(s) for this transaction. This will credit # the account, thereby transferring value from the debit_note(s) to # that account. If this fails then the debit_note(s) needs to # be refunded credit_notes = {} has_error = False credit_error = Exception() for debit_note in debit_notes: try: credit_note = _CreditNote(debit_note, credit_account, bucket=bucket) credit_notes[debit_note.uid()] = credit_note except Exception as e: has_error = True credit_error = e break if has_error: # something went wrong crediting the account... We need to refund # the transaction - first retract the credit notes... try: for credit_note in credit_notes.values(): credit_account._delete_note(credit_note, bucket=bucket) except Exception as e: raise UnbalancedLedgerError( "We have an unbalanced ledger as it was not " "possible to credit a multi-part debit (%s): Credit " "refusal error = %s. Refund error = %s" % (debit_notes, str(credit_error), str(e))) # now refund all of the debit notes try: for debit_note in debit_notes: debit_account._delete_note(debit_note, bucket=bucket) except Exception as e: raise UnbalancedLedgerError( "We have an unbalanced ledger as it was not " "possible to credit a multi-part debit (%s): Credit " "refusal error = %s. Refund error = %s" % (debit_notes, str(credit_error), str(e))) raise credit_error try: paired_notes = _PairedNote.create(debit_notes, credit_notes) except Exception as e: # delete all of the notes... for debit_note in debit_notes: try: debit_account._delete_note(debit_note, bucket=bucket) except: pass for credit_note in credit_notes: try: credit_account._delete_note(credit_note, bucket=bucket) except: pass raise e # now write the paired entries to the ledger. The below function # is guaranteed not to raise an exception return Ledger._record_to_ledger(paired_notes, is_provisional, bucket=bucket)
def _reconcile_daily_accounts(self, bucket=None): """Internal function used to reconcile the daily accounts. This ensures that every line item transaction is summed up so that the starting balance for each day is recorded into the object store """ if self.is_null(): return if bucket is None: bucket = _login_to_service_account() # work back from today to the first day of the account to calculate # all of the daily balances... We need to record every day of the # account to support quick lookups today = _datetime.datetime.now().toordinal() day = today last_data = None num_missing_days = 0 while last_data is None: daytime = _datetime.datetime.fromordinal(day) key = self._get_balance_key(daytime) last_data = _ObjectStore.get_object_from_json(bucket, key) if last_data is None: day -= 1 num_missing_days += 1 if num_missing_days > 100: # we need another strategy to find the last balance break if last_data is None: # find the latest day by reading the keys in the object # store directly root = "%s/balance/" % self._key() keys = _ObjectStore.get_all_object_names(bucket, root) if keys is None or len(keys) == 0: raise AccountError("There is no daily balance recorded for " "the account with UID %s" % self.uid()) # the encoding of the keys is such that, when sorted, the # last key must be the latest balance keys.sort() last_data = _ObjectStore.get_object_from_json( bucket, "%s%s" % (root, keys[-1])) day = _get_day_from_key(keys[-1]).toordinal() if last_data is None: raise AccountError("How can there be no data for key %s?" % keys[-1]) # what was the balance on the last day? result = (_create_decimal(last_data["balance"]), _create_decimal(last_data["liability"]), _create_decimal(last_data["receivable"])) # ok, now we go from the last day until today and sum up the # line items from each day to create the daily balances # (not including today, as we only want the balance at the beginning # of today) for d in range(day + 1, today + 1): day_time = _datetime.datetime.fromordinal(d) transaction_keys = self._get_transaction_keys_between( _datetime.datetime.fromordinal(d - 1), day_time) total = _sum_transactions(transaction_keys) result = (result[0] + total[0], result[1] + total[1], result[2] + total[2]) balance_key = self._get_balance_key(day_time) data = {} data["balance"] = str(result[0]) data["liability"] = str(result[1]) data["receivable"] = str(result[2]) _ObjectStore.set_object_from_json(bucket, balance_key, data)
def receipt(receipt, bucket=None): """Create and record a new transaction from the passed receipt. This applies the receipt, thereby actually transferring value from the debit account to the credit account of the corresponding transaction. Note that you can only receipt a transaction once! This returns the (already recorded) TransactionRecord for the receipt """ if not isinstance(receipt, _Receipt): raise TypeError("The Receipt must be of type Receipt") if receipt.is_null(): return _TransactionRecord() if bucket is None: bucket = _login_to_service_account() # extract value into the debit note debit_account = _Account(uid=receipt.debit_account_uid(), bucket=bucket) credit_account = _Account(uid=receipt.credit_account_uid(), bucket=bucket) debit_note = _DebitNote(receipt=receipt, account=debit_account, bucket=bucket) # now create the credit note to put the value into the credit account try: credit_note = _CreditNote(debit_note=debit_note, receipt=receipt, account=credit_account, bucket=bucket) except Exception as e: # delete the debit note try: debit_account._delete_note(debit_note, bucket=bucket) except: pass # reset the transaction to the pending state try: _TransactionRecord.load_test_and_set( receipt.transaction_uid(), _TransactionState.RECEIPTING, _TransactionState.PENDING, bucket=bucket) except: pass raise e try: paired_notes = _PairedNote.create(debit_note, credit_note) except Exception as e: # delete all records...! try: debit_account._delete_note(debit_note, bucket=bucket) except: pass try: credit_account._delete_note(credit_note, bucket=bucket) except: pass # reset the transaction to the pending state try: _TransactionRecord.load_test_and_set( receipt.transaction_uid(), _TransactionState.RECEIPTING, _TransactionState.PENDING, bucket=bucket) except: pass raise e # now record the two entries to the ledger. The below function # is guaranteed not to raise an exception return Ledger._record_to_ledger(paired_notes, receipt=receipt, bucket=bucket)
def create_account(self, name, description=None, overdraft_limit=None, bucket=None): """Create a new account called 'name' in this group. This will return the existing account if it already exists """ if name is None: raise ValueError("You must pass a name of the new account") account_key = self._account_key(name) if bucket is None: bucket = _login_to_service_account() try: account_uid = _ObjectStore.get_string_object(bucket, account_key) except: account_uid = None if account_uid is not None: # this account already exists - just return it account = _Account(uid=account_uid, bucket=bucket) if overdraft_limit is not None: account.set_overdraft_limit(overdraft_limit, bucket=bucket) return account # make sure that no-one has created this account before m = _Mutex(account_key, timeout=600, lease_time=600, bucket=bucket) try: account_uid = _ObjectStore.get_string_object(bucket, account_key) except: account_uid = None if account_uid is not None: m.unlock() # this account already exists - just return it account = _Account(uid=account_uid, bucket=bucket) if overdraft_limit is not None: account.set_overdraft_limit(overdraft_limit, bucket=bucket) return account # write a temporary UID to the object store so that we # can ensure we are the only function to create it try: _ObjectStore.set_string_object(bucket, account_key, "under_construction") except: m.unlock() raise m.unlock() # ok - we are the only function creating this account. Let's try # to create it properly try: account = _Account(name=name, description=description, bucket=bucket) except: try: _ObjectStore.delete_object(bucket, account_key) except: pass raise if overdraft_limit is not None: account.set_overdraft_limit(overdraft_limit, bucket=bucket) _ObjectStore.set_string_object(bucket, account_key, account.uid()) return account
def _debit(self, transaction, authorisation, is_provisional, 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 timestamp of the debit. If this transaction 'is_provisional' then it will be recorded as a liability. 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 """ if self.is_null() or transaction.value() <= 0: return None if not isinstance(transaction, _Transaction): raise TypeError("The passed transaction must be a Transaction!") self.assert_valid_authorisation(authorisation) if bucket is None: bucket = _login_to_service_account() if self.available_balance(bucket) < transaction.value(): raise InsufficientFundsError( "You cannot debit '%s' from account %s as there " "are insufficient funds in this account." % (transaction, str(self))) # create a UID and timestamp for this debit and record # it in the account now = self._get_safe_now() # we need to record the exact timestamp of this debit... timestamp = now.timestamp() # and to create a key to find this debit later. The key is made # up from the date and timestamp of the debit and a random string day_key = "%4d-%02d-%02d/%s" % (now.year, now.month, now.day, timestamp) uid = "%s/%s" % (day_key, str(_uuid.uuid4())[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._key(), uid, encoded_value) # create a line_item for this debit and save it to the object store line_item = _LineItem(uid, authorisation) _ObjectStore.set_object_from_json(bucket, item_key, line_item.to_data()) if self.is_beyond_overdraft_limit(bucket): # This transaction has helped push the account beyond the # overdraft limit. Delete the transaction and raise # an InsufficientFundsError _ObjectStore.delete_object(bucket, item_key) raise InsufficientFundsError( "You cannot debit '%s' from account %s as there " "are insufficient funds in this account." % (transaction, str(self))) return (uid, timestamp)
def _save_account(self, bucket=None): """Save this account back to the object store""" if bucket is None: bucket = _login_to_service_account() _ObjectStore.set_object_from_json(bucket, self._key(), self.to_data())
def refund(refund, bucket=None): """Create and record a new transaction from the passed refund. This applies the refund, thereby transferring value from the credit account to the debit account of the corresponding transaction. Note that you can only refund a transaction once! This returns the (already recorded) TransactionRecord for the refund """ if not isinstance(refund, _Refund): raise TypeError("The Refund must be of type Refund") if refund.is_null(): return _TransactionRecord() if bucket is None: bucket = _login_to_service_account() # return value from the credit to debit accounts debit_account = _Account(uid=refund.debit_account_uid(), bucket=bucket) credit_account = _Account(uid=refund.credit_account_uid(), bucket=bucket) # remember that a refund debits from the original credit account... # (and can only refund completed (DIRECT) transactions) debit_note = _DebitNote(refund=refund, account=credit_account, bucket=bucket) # now create the credit note to return the value into the debit account try: credit_note = _CreditNote(debit_note=debit_note, refund=refund, account=debit_account, bucket=bucket) except Exception as e: # delete the debit note try: debit_account._delete_note(debit_note, bucket=bucket) except: pass # reset the transaction to its original state try: _TransactionRecord.load_test_and_set( refund.transaction_uid(), _TransactionState.REFUNDING, _TransactionState.DIRECT, bucket=bucket) except: pass raise e try: paired_notes = _PairedNote.create(debit_note, credit_note) except Exception as e: # delete all records...! try: debit_account._delete_note(debit_note, bucket=bucket) except: pass try: credit_account._delete_note(credit_note, bucket=bucket) except: pass # reset the transaction to the pending state try: _TransactionRecord.load_test_and_set( refund.transaction_uid(), _TransactionState.REFUNDING, _TransactionState.DIRECT, bucket=bucket) except: pass raise e # now record the two entries to the ledger. The below function # is guaranteed not to raise an exception return Ledger._record_to_ledger(paired_notes, refund=refund, bucket=bucket)