class _Entries(Encodable): def __init__(self, entries: List[Entry]) -> None: if not isinstance(entries, list): raise TypeError('entries must be of type List[Entry]') if False in [isinstance(e, Entry) for e in entries]: raise TypeError('entries must be of type List[Entry]') debits = sum([e.amount for e in entries if e.side == Side.debit]) credits_ = sum( [e.amount for e in entries if e.side == Side.credit]) if debits != credits_: raise ValueError('sum of debits must equal sum of credits') self._entries = entries return entries = Immutable(lambda s: s._entries) debits = Immutable( lambda s: [e for e in s._entries if e.side == Side.debit]) credits_ = Immutable( lambda s: [e for e in s._entries if e.side == Side.credit]) def serialise(self) -> List[Dict]: return [e.serialise() for e in self._entries]
class Region: """ A geographic region in which Amatino can store accounting information. """ def __init__(self, id_: int, region_code: str) -> None: self._id = id_ self._region_code = region_code return id_ = Immutable(lambda s: s._id)
class Denominated: """ Abstract class defining an interface for objects that are denominated in an Amatino unit of account, i.e. in a Custom Unit or Global Unit. Provides default functionality for determining what unit denominates an object. Provides default caching capacity for retrieved Custom Units and Global Units such that repeated requests for them from properties will not result in extra synchronous calls to the Amatino API """ global_unit_id = NotImplemented custom_unit_id = NotImplemented denomination = Immutable(lambda s: s._denomination()) entity = NotImplemented _denominated_cached_custom_unit = None _denominated_cached_global_unit = None def _denomination(self) -> Denomination: """Return the unit denominating this object""" if self.global_unit_id == NotImplemented: raise NotImplementedError('Implement .global_unit_id property') if self.custom_unit_id == NotImplemented: raise NotImplementedError('Implement .custom_unit_id property') if not isinstance(self.entity, Entity): raise NotImplementedError('Implement .entity property') if self.global_unit_id is not None and self.custom_unit_id is not None: raise AssertionError('Both global & custom units supplied!') if self.global_unit_id is not None: assert isinstance(self.global_unit_id, int) if self.custom_unit_id is not None: assert isinstance(self.custom_unit_id, int) if self.global_unit_id is not None: assert isinstance(self.global_unit_id, int) if self._denominated_cached_global_unit is not None: return self._denominated_cached_global_unit global_unit = GlobalUnit.retrieve(self.entity.session, self.global_unit_id) self._denominated_cached_global_unit = global_unit return global_unit if self._denominated_cached_custom_unit is not None: return self._denominated_cached_custom_unit custom_unit = CustomUnit.retrieve(self.entity, self.entity.session, self.custom_unit_id) self._denominated_cached_custom_unit = custom_unit return custom_unit
class LedgerRow: """ A Ledger Row is a specialised view of a Transaction, delivered as part of a Ledger or Recursive Ledger. The Ledger Row describes a Tranasction from the perspective of the Account targeted by the controlling Ledger or Recursive Ledger. When consuming the Amatino API, you will never encounter a Ledger Row on its own. They are only ever delivered under the ledger_rows key as part of a Ledger or Recursive Ledger object. """ def __init__(self, transaction_id: int, transaction_time: AmatinoTime, description: str, opposing_account_id: Optional[int], opposing_account_name: str, debit: Decimal, credit: Decimal, balance: Decimal) -> None: self._transaction_id = transaction_id self._transaction_time = transaction_time self._description = description self._opposing_account_id = opposing_account_id self._opposing_account_name = opposing_account_name self._debit = debit self._credit = credit self._balance = balance return transaction_id = Immutable(lambda s: s._transaction_id) transaction_time = Immutable(lambda s: s._transaction_time.raw) description = Immutable(lambda s: s._description) opposing_account_id = Immutable(lambda s: s._opposing_account_id) opposing_account_name = Immutable(lambda s: s._opposing_account_name) debit = Immutable(lambda s: s._debit) credit = Immutable(lambda s: s._credit) balance = Immutable(lambda s: s._balance) def opposing_account(self, session: Session, entity: Entity) -> Optional[Account]: """Return the account opposing this transaction, or, if many, None""" if self.opposing_account_id is None: return None return Account.retrieve(session, entity, self.opposing_account_id) def retrieve_transaction(self, session: Session, entity: Entity) -> Transaction: """Retrieve the Transaction this LedgerRow describes""" return Transaction.retrieve(session, entity, self.transaction_id)
class Denomination: """ Abstract class defining an interface for units of account. Adopted by Custom Units and Global Units. """ def __init__(self, code: str, id_: int, name: str, priority: int, description: str, exponent: int) -> None: self._code = code self._id = id_ self._name = name self._priority = priority self._description = description self._exponent = exponent return code = Immutable(lambda s: s._code) id_ = Immutable(lambda s: s._id) name = Immutable(lambda s: s._name) priority = Immutable(lambda s: s._priority) description = Immutable(lambda s: s._description) exponent = Immutable(lambda s: s._exponent)
class Tree(Decodable, Denominated): """ Trees present the entire chart of Accounts of an Entity in a single hierarchical object. Each Account is nested under its parent, and in turn lists all its children, all providing Balances and Recursive Balances. Trees are trimmed for permissions. If the user requesting the tree only has read access to a subset of Accounts, they will only receive a tree containing those accounts, with placeholder objects filling the place of Accounts they are not permitted to read. Each Account in the Tree is presented as a Tree Node. """ _PATH = '/trees' def __init__( self, entity: Entity, balance_time: AmatinoTime, generated_time: AmatinoTime, global_unit_denomination: Optional[int], custom_unit_denomination: Optional[int], tree: List[TreeNode] ) -> None: assert isinstance(entity, Entity) assert isinstance(balance_time, AmatinoTime) assert isinstance(generated_time, AmatinoTime) if global_unit_denomination is not None: assert isinstance(global_unit_denomination, int) if custom_unit_denomination is not None: assert isinstance(custom_unit_denomination, int) assert isinstance(tree, list) assert False not in [isinstance(t, TreeNode) for t in tree] self._entity = entity self._balance_time = balance_time self._generated_time = generated_time self._global_unit_id = global_unit_denomination self._custom_unit_id = custom_unit_denomination self._tree = tree return entity = Immutable(lambda s: s._entity) session = Immutable(lambda s: s._entity.session) balance_time = Immutable(lambda s: s._balance_time.raw) generated_time = Immutable(lambda s: s._generated_time.raw) custom_unit_id = Immutable(lambda s: s._custom_unit_id) global_unit_id = Immutable(lambda s: s._global_unit_id) nodes = Immutable(lambda s: s._tree) has_accounts = Immutable(lambda s: len(s._tree) > 0) income = Immutable(lambda s: s.nodes_of_type(AMType.income)) expenses = Immutable(lambda s: s.nodes_of_type(AMType.expense)) assets = Immutable(lambda s: s.nodes_of_type(AMType.asset)) liabilities = Immutable(lambda s: s.nodes_of_type(AMType.liability)) equities = Immutable(lambda s: s.nodes_of_type(AMType.equity)) has_assets = Immutable(lambda s: len(s.nodes_of_type(AMType.asset)) > 0) has_liabilities = Immutable(lambda s: len( s.nodes_of_type(AMType.liability)) > 0 ) has_income = Immutable(lambda s: len(s.nodes_of_type(AMType.income)) > 0) has_expenses = Immutable(lambda s: len(s.nodes_of_type(AMType.expense)) > 0) has_equity = Immutable(lambda s: len(s.nodes_of_type(AMType.equity)) > 0) total_assets = Immutable(lambda s: s._total(AMType.asset)) total_expenses = Immutable(lambda s: s._total(AMType.expense)) total_liabilities = Immutable(lambda s: s._total(AMType.liability)) total_income = Immutable(lambda s: s._total(AMType.income)) total_equity = Immutable(lambda s: s._total(AMType.equity)) def nodes_of_type(self, am_type: AMType) -> List[TreeNode]: """Return top-level TreeNodes of the supplied AMType""" if not isinstance(am_type, AMType): raise TypeError('am_type must be of type `AMType`') if not self.has_accounts: return list() nodes = [n for n in self._tree if n.am_type == am_type] return nodes def _total(self, am_type: AMType) -> Decimal: """Return total recursive balance for accounts with supplied type""" assert isinstance(am_type, AMType) accounts = self.nodes_of_type(am_type) if len(accounts) < 1: return Decimal(0) total = sum([a.recursive_balance for a in accounts]) assert isinstance(total, Decimal) return total @classmethod def decode(cls: Type[T], entity: Entity, data: Any) -> T: if not isinstance(data, dict): raise UnexpectedResponseType(data, dict) try: tree = cls( entity=entity, balance_time=AmatinoTime.decode(data['balance_time']), generated_time=AmatinoTime.decode(data['generated_time']), global_unit_denomination=data['global_unit_denomination'], custom_unit_denomination=data['custom_unit_denomination'], tree=TreeNode.decode_many(entity, data['tree']) ) except KeyError as error: raise MissingKey(error.args[0]) return tree @classmethod def retrieve( cls: Type[T], entity: Entity, balance_time: datetime, denomination: Denomination ) -> T: arguments = cls.RetrieveArguments( balance_time=balance_time, denomination=denomination ) return cls._retrieve(entity, arguments) @classmethod def _retrieve( cls: Type[T], entity: Entity, arguments: K ) -> T: if not isinstance(entity, Entity): raise TypeError('entity must be of type Entity') assert isinstance(arguments, cls.RetrieveArguments) data = DataPackage(object_data=arguments, override_listing=True) parameters = UrlParameters(entity_id=entity.id_) request = ApiRequest( path=cls._PATH, method=HTTPMethod.GET, credentials=entity.session, data=data, url_parameters=parameters ) return cls.decode(entity, request.response_data) class RetrieveArguments(Encodable): def __init__( self, balance_time: datetime, denomination: Denomination ) -> None: if not isinstance(balance_time, datetime): raise TypeError('balance_time must be of type `datetime`') if not isinstance(denomination, Denomination): raise TypeError('denomination must be of type `denomination`') self._balance_time = AmatinoTime(balance_time) self._denomination = denomination def serialise(self) -> Dict[str, Any]: global_unit_id = None custom_unit_id = None if isinstance(self._denomination, GlobalUnit): global_unit_id = self._denomination.id_ else: assert isinstance(self._denomination, CustomUnit) custom_unit_id = self._denomination.id_ data = { 'balance_time': self._balance_time.serialise(), 'custom_unit_denomination': custom_unit_id, 'global_unit_denomination': global_unit_id } return data
class Entity(SessionDecodable): """ An Amatino entity is a economic unit to be described by accounting information. Entities are described by accounts, transactions, and entries. An example of an entity might be a legal company, a consolidated group of companies, a project, or even a person. """ PATH = '/entities' LIST_PATH = '/entities/list' MAX_NAME_LENGTH = 1024 MAX_DESCRIPTION_LENGTH = 4096 MAX_NAME_SEARCH_LENGTH = 64 MIN_NAME_SEARCH_LENGTH = 3 def __init__(self, session: Session, entity_id: str, name: str, description: str, region_id: int, owner_id: int, permissions_graph: PermissionsGraph, disposition: Disposition) -> None: self._session = session self._entity_id = entity_id self._name = name self._description = description self._region_id = region_id self._owner_id = owner_id self._permissions_graph = permissions_graph self._disposition = disposition return session = Immutable(lambda s: s._session) id_ = Immutable(lambda s: s._entity_id) name = Immutable(lambda s: s._name) description = Immutable(lambda s: s._description) region_id = Immutable(lambda s: s._region_id) owner_id = Immutable(lambda s: s._owner_id) permissions_graph = Immutable(lambda s: s._permissions_graph) disposition = Immutable(lambda s: s._disposition) @classmethod def create(cls: Type[T], session: Session, name: str, description: Optional[str], region: Optional[Region] = None) -> T: new_entity_arguments = cls.CreateArguments(name, description, region) request_data = DataPackage.from_object(new_entity_arguments) request = ApiRequest(Entity.PATH, HTTPMethod.POST, session, request_data, None, False) created_entity = cls.decode(request.response_data, session) return created_entity @classmethod def decode(cls: Type[T], data: Any, session: Session) -> T: """ Return an Entity instance decoded from API response data """ if isinstance(data, list): data = data[0] assert isinstance(session, Session) return cls(session=session, entity_id=data['entity_id'], name=data['name'], description=data['description'], region_id=data['region_id'], owner_id=data['owner'], permissions_graph=PermissionsGraph( data['permissions_graph']), disposition=Disposition.decode(data['disposition'])) @classmethod def retrieve(cls: Type[T], session: Session, entity_id: str) -> T: if not isinstance(session, Session): raise TypeError('session must be of type `Session`') if not isinstance(entity_id, str): raise TypeError('entity_id must be of type `str`') url_parameters = UrlParameters(entity_id=entity_id) request = ApiRequest(path=Entity.PATH, method=HTTPMethod.GET, credentials=session, data=None, url_parameters=url_parameters, debug=False) return cls.decode(request.response_data, session) @classmethod def retrieve_list(cls: Type[T], session: Session, state: State = State.ALL, offset: int = 0, limit: int = 10, name_fragment: Optional[str] = None) -> List[T]: if not isinstance(session, Session): raise TypeError('session must be of type `amatino.Session`') if not isinstance(offset, int): raise TypeError('offset must be of type `int`') if not isinstance(limit, int): raise TypeError('limit must be of type `int`') if not isinstance(state, State): raise TypeError('state must be of type `amatino.State`') if name_fragment is not None: if not isinstance(name_fragment, str): raise TypeError('name_fragment must be of type `str`') if len(name_fragment) < cls.MIN_NAME_SEARCH_LENGTH: raise ValueError( 'name_fragment minimum length is {c} char'.format( c=str(cls.MIN_NAME_SEARCH_LENGTH))) if len(name_fragment) > cls.MAX_NAME_SEARCH_LENGTH: raise ValueError( 'name_fragment maximum length is {c} char'.format( c=str(cls.MAX_NAME_SEARCH_LENGTH))) url_targets = [ UrlTarget('limit', limit), UrlTarget('offset', offset), UrlTarget('state', state.value) ] if name_fragment is not None: url_targets.append(UrlTarget('name', name_fragment)) url_parameters = UrlParameters(targets=url_targets) request = ApiRequest(path=Entity.LIST_PATH, method=HTTPMethod.GET, credentials=session, data=None, url_parameters=url_parameters) return cls.optionally_decode_many(data=request.response_data, session=session, default_to_empty_list=True) def update( self, name: Optional[str] = None, description: Optional[str] = None, owner: Optional[User] = None, permissions_graph: Optional[PermissionsGraph] = None) -> 'Entity': """ Modify data describing this Entity. Returns this Entity, the Entity instance is not modified in place. """ owner_id = None if owner is not None: owner_id = owner.id_ update_arguments = Entity.UpdateArguments( self, name=name, description=description, owner_id=owner_id, permissions_graph=permissions_graph) data_package = DataPackage.from_object(data=update_arguments) request = ApiRequest(path=Entity.PATH, method=HTTPMethod.PUT, credentials=self._session, data=data_package, url_parameters=None) updated_entity = Entity.decode(request.response_data, self.session) return updated_entity def delete(self) -> None: """ Destroy this Entity. Deleted entities can be recovered if necessary. Returns None, the Entity is updated-in-place. """ raise NotImplementedError def restore(self) -> None: """ Restore this Entity from a deleted state. Returns None, the Entity is updated-in-place. """ raise NotImplementedError class _Description(ConstrainedString): def __init__(self, description: str) -> None: super().__init__(description, 'description', Entity.MAX_DESCRIPTION_LENGTH) return class _Name(ConstrainedString): def __init__(self, name: str) -> None: super().__init__(name, 'name', Entity.MAX_NAME_LENGTH) return class UpdateArguments(Encodable): def __init__( self, entity: T, name: Optional[str] = None, description: Optional[str] = None, owner_id: Optional[int] = None, permissions_graph: Optional[PermissionsGraph] = None) -> None: if not isinstance(entity, Entity): raise TypeError('entity must be of type `Entity`') self._entity = entity if name: self._name = Entity._Name(name).serialise() else: self._name = entity.name if description: self._description = Entity._Description.serialise() else: self._description = entity._description if owner_id: if not isinstance(owner_id, int): raise TypeError('owner_id must be of type `int`') self._owner_id = owner_id else: self._owner_id = entity.owner_id if permissions_graph: if not isinstance(permissions_graph, PermissionsGraph): raise TypeError('graph must be of type `PermissionsGraph`') self._permissions_graph = permissions_graph else: self._permissions_graph = entity.permissions_graph return def serialise(self) -> Dict[str, Any]: data = { 'name': self._name, 'description': self._description, 'entity_id': self._entity.id_, 'owner': self._owner_id, 'permissions_graph': self._permissions_graph.serialise() } return data class CreateArguments(Encodable): def __init__(self, name: str, description: Optional[str], region: Optional[Region] = None) -> None: self._name = ConstrainedString(name, 'name', Entity.MAX_NAME_LENGTH) self._description = ConstrainedString( description or '', 'description', Entity.MAX_DESCRIPTION_LENGTH) if region is not None and not isinstance(region, Region): raise TypeError('region must be of type `Region`') self._region = region return def serialise(self) -> Dict[str, Any]: region_id = None if isinstance(self._region, Region): region_id = self._region.id_ data = { 'name': self._name.serialise(), 'description': self._description.serialise(), 'region_id': region_id } return data
class AccountTest(EntityTest): """Test the Account primary object""" def __init__(self, name='Create, retrieve, update an Account') -> None: super().__init__(name) self.create_entity() if not isinstance(self.entity, Entity): raise RuntimeError( 'Entity creation failed. Consider running Entity tests' ) return usd = Immutable(lambda s: s._usd()) _account_test_cached_usd = None def _usd(self) -> GlobalUnit: if self._account_test_cached_usd is not None: return self._account_test_cached_usd usd = GlobalUnit.retrieve(self.session, USD_UNIT_ID) self._account_test_cached_usd = usd return usd def create_account(self, amt=AMType.asset, name='Test account') -> Account: account = Account.create( self.entity, name, amt, self.usd, 'A test Account created by the Python test suite' ) self.account = account return account def execute(self) -> None: try: account = self.create_account() except Exception as error: self.record_failure(error) return assert isinstance(account, Account) try: account = Account.retrieve( self.entity, account.id_ ) except Exception as error: self.record_failure(error) return if account.id_ != self.account.id_: self.record_failure('Account IDs do not match') return new_name = 'Updated account name' try: updated_account = account.update(name=new_name) except Exception as error: self.record_failure(error) return if updated_account.name != new_name: self.record_failure('Account name was not updated') return self.record_success() return
class Account(Denominated): """ An Amatino Account is collection of related economic activity. For example, an Account might represent a bank account, income from a particular client, or company equity. Many Accounts together compose an Entity. """ _PATH = '/accounts' MAX_DESCRIPTION_LENGTH = 1024 MAX_NAME_LENGTH = 1024 _URL_KEY = 'account_id' def __init__(self, entity: Entity, account_id: int, name: str, am_type: AMType, description: str, parent_account_id: int, global_unit_id: Optional[int], custom_unit_id: Optional[int], counterparty_id: Optional[str], color: Color) -> None: self._entity = entity self._id = account_id self._name = name self._am_type = am_type self._description = description self._parent_account_id = parent_account_id self._global_unit_id = global_unit_id self._custom_unit_id = custom_unit_id self._counterparty_id = counterparty_id self._color = color self._cached_denomination = None return session = Immutable(lambda s: s._entity.session) entity = Immutable(lambda s: s._entity) id_ = Immutable(lambda s: s._id) name = Immutable(lambda s: s._name) am_type = Immutable(lambda s: s._am_type) description = Immutable(lambda s: s._description) global_unit_id = Immutable(lambda s: s._global_unit_id) custom_unit_id = Immutable(lambda s: s._custom_unit_id) counterparty_id = Immutable(lambda s: s._counterparty_id) color = Immutable(lambda s: s._color) parent_id = Immutable(lambda s: s._parent_account_id) parent = Immutable(lambda s: s._parent()) @classmethod def create(cls: Type[T], entity: Entity, name: str, am_type: AMType, denomination: Denomination, description: Optional[str] = None, parent: Optional[T] = None, counter_party: Optional[Entity] = None, color: Optional[Color] = None) -> T: arguments = Account.CreateArguments(name, description, am_type, parent, denomination, counter_party, color) data = DataPackage.from_object(arguments) parameters = UrlParameters(entity_id=entity.id_) request = ApiRequest(path=Account._PATH, method=HTTPMethod.POST, credentials=entity.session, data=data, url_parameters=parameters) account = cls._decode(entity, request.response_data) return account @classmethod def retrieve(cls: Type[T], entity: Entity, account_id: int) -> T: """ Return an existing Account """ target = UrlTarget.from_integer(key=Account._URL_KEY, value=account_id) url_parameters = UrlParameters(entity_id=entity.id_, targets=[target]) request = ApiRequest(path=Account._PATH, method=HTTPMethod.GET, credentials=entity.session, data=None, url_parameters=url_parameters) account = cls._decode(entity, request.response_data) return account def update(self: T, name: Optional[str] = None, am_type: Optional[AMType] = None, parent: Optional[T] = None, description: Optional[str] = None, denomination: Optional[Denomination] = None, counterparty: Optional[Entity] = None, color: Optional[Color] = None) -> 'Account': """ Update this Account with new metadata. """ arguments = Account.UpdateArguments(self, name, am_type, parent, description, denomination, counterparty, color) data = DataPackage.from_object(arguments) parameters = UrlParameters(entity_id=self.entity.id_) request = ApiRequest(path=Account._PATH, method=HTTPMethod.PUT, credentials=self.entity.session, data=data, url_parameters=parameters) account = Account._decode(self.entity, request.response_data) if account.id_ != self.id_: raise ApiError('Returned Account ID does not match request ID') return account def delete(self): raise NotImplementedError class CreateArguments(Encodable): def __init__(self, name: str, description: Optional[str], am_type: AMType, parent: Optional[T], denomination: Denomination, counter_party: Optional[Entity], color: Optional[Color]) -> None: self._name = Account._Name(name) if description is None: description = '' self._description = Account._Description(description) if not isinstance(am_type, AMType): raise TypeError('am_type must be of type `AMType`') self._type = am_type self._parent = Account._Parent(parent) self._global_unit = Account._GlobalUnit(denomination) self._custom_unit = Account._CustomUnit(denomination) if counter_party and not isinstance(counter_party, Entity): raise TypeError('counter_party must be of type `Entity`') self._counterparty = counter_party if color and not isinstance(color, Color): raise TypeError('color must be of type `Color`') self._color = color return def serialise(self) -> Any: counterparty_id = None if self._counterparty: counterparty_id = self._counterparty.id_ color_code = None if self._color: color_code = str(self._color) data = { 'name': self._name.serialise(), 'type': self._type.value, 'description': self._description.serialise(), 'parent_account_id': self._parent.serialise(), 'counterparty_entity_id': counterparty_id, 'global_unit_id': self._global_unit.serialise(), 'custom_unit_id': self._custom_unit.serialise(), 'colour': color_code } return data @classmethod def _decode(cls: Type[T], entity: Entity, data: List[dict]) -> T: return cls._decode_many(entity, data)[0] @classmethod def _decode_many(cls: Type[T], entity: Entity, data: List[dict]) -> List[T]: if not isinstance(data, list): raise ApiError('Unexpected non-list data returned') if len(data) < 1: raise ApiError('Unexpected empty response data') def decode(data: dict) -> T: if not isinstance(data, dict): raise ApiError('Unexpected non-dict data returned') try: account = cls(entity=entity, account_id=data['account_id'], name=data['name'], am_type=AMType(data['type']), description=data['description'], parent_account_id=data['parent_account_id'], global_unit_id=data['global_unit_id'], custom_unit_id=data['custom_unit_id'], counterparty_id=data['counterparty_entity_id'], color=Color.from_hex_string(data['colour'])) except KeyError as error: raise MissingKey(error.args[0]) return account accounts = [decode(a) for a in data] return accounts def _parent(self) -> Optional['Account']: """Return this Account's parent, if it has one""" if self.parent_id is None: return None assert isinstance(self.parent_id, int) return Account.retrieve(self.entity, self.parent_id) class UpdateArguments(Encodable): def __init__(self, account: T, name: Optional[str], am_type: Optional[AMType], parent: Optional[T], description: Optional[str], denomination: Optional[Denomination], counterparty: Optional[Entity], color: Optional[Color]) -> None: if not isinstance(account, Account): raise TypeError('account must be of type `Account`') self._account_id = account.id_ if not name: name = account.name assert isinstance(name, str) self._name = Account._Name(name) if am_type: if not isinstance(am_type, AMType): raise TypeError('am_type must be of type `AMType`') else: am_type = account.am_type self._type = am_type if not description: description = account.description self._description = Account._Description(description) if not parent: self._parent_id = account.parent_id else: self._parent_id = Account._Parent(parent).serialise() if denomination: self._global_unit_id = Account._GlobalUnit( denomination).serialise() self._custom_unit_id = Account._CustomUnit( denomination).serialise() else: self._global_unit_id = account.global_unit_id self._custom_unit_id = account.custom_unit_id if counterparty: if not isinstance(counterparty, Entity): raise TypeError('counterparty must be of type `Entity`') self._counterparty_id = counterparty.id_ else: self._counterparty_id = account.counterparty_id if color: if not isinstance(color, Color): raise TypeError('color must be of type `Color`') self._color = color else: self._color = account.color return def serialise(self) -> Any: data = { 'name': self._name.serialise(), 'account_id': self._account_id, 'description': self._description.serialise(), 'type': self._type.value, 'parent_account_id': self._parent_id, 'global_unit_id': self._global_unit_id, 'custom_unit_id': self._custom_unit_id, 'colour': self._color.serialise(), 'counterparty_entity_id': self._counterparty_id } return data class _Name(Encodable): def __init__(self, string: str) -> None: if not isinstance(string, str): raise TypeError('name must be of type `str`') self._name = ConstrainedString(string, 'name', Account.MAX_NAME_LENGTH) return def serialise(self) -> str: return str(self._name) class _Description(Encodable): def __init__(self, string: Optional[str]) -> None: if string is not None and not isinstance(string, str): raise TypeError('description must be of type `str` or None') self._description = ConstrainedString( string, 'description', Account.MAX_DESCRIPTION_LENGTH) return def serialise(self) -> str: return str(self._description) class _Parent(Encodable): def __init__(self, parent: Optional[T]) -> None: if parent is not None and not isinstance(parent, Account): raise TypeError('parent must be of type `Account`') self._parent_id = None if parent is not None: self._parent_id = parent.id_ return def serialise(self) -> Optional[int]: return self._parent_id class _CustomUnit(Encodable): def __init__(self, denomination: Denomination) -> None: if not isinstance(denomination, Denomination): raise TypeError('denomination must be of type `Denomination`') self._custom_unit_id = None if isinstance(denomination, CustomUnit): self._custom_unit_id = denomination.id_ return def serialise(self) -> Optional[int]: return self._custom_unit_id class _GlobalUnit(Encodable): def __init__(self, denomination: Denomination) -> None: if not isinstance(denomination, Denomination): raise TypeError('denomination must be of type `Denomination`') self._global_unit_id = None if isinstance(denomination, GlobalUnit): self._global_unit_id = denomination.id_ return def serialise(self) -> Optional[int]: return self._global_unit_id
class CustomUnit(Denomination): """ Custom Units are units of account created by Amatino users. Their scope is limited to the Entity in which they are created. They can be used anywhere a Global unit would be used, allowing a user to denominate their Transactions and Accounts as they please. Custom Unit identifiers must be unique with reference to each other, but need not be so with reference to Global Units. Therefore, it is possible to create a Custom Unit implementation of a Global Unit - For example, a USD Custom Unit using a preferred source of foreign exchange rates. """ MAX_DESCRIPTION_LENGTH = 1024 MIN_CODE_LENGTH = 3 MAX_CODE_LENGTH = 64 MAX_NAME_LENGTH = 1024 MAX_EXPONENT_VALUE = 6 MIN_EXPONENT_VALUE = 0 MAX_PRIORITY_VALUE = 10000 MIN_PRIORITY_VALUE = -10000 _URL_KEY = 'custom_unit_id' _PATH = '/custom_units' def __init__( self, entity: Entity, id_: int, code: str, name: str, priority: int, description: str, exponent: int ) -> None: self._entity = entity super().__init__(code, id_, name, priority, description, exponent) return session = Immutable(lambda s: s._entity._session) entity = Immutable(lambda s: s._entity) @classmethod def create( cls: Type[T], entity: Entity, name: str, code: str, exponent: int, description: Optional[str] = None, priority: Optional[int] = None ) -> T: arguments = CustomUnit.CreationArguments( code, name, exponent, priority, description ) parameters = UrlParameters(entity_id=entity.id_) request = ApiRequest( path=CustomUnit._PATH, credentials=entity.session, method=HTTPMethod.POST, data=DataPackage.from_object(arguments), url_parameters=parameters ) return CustomUnit._decode(entity, request.response_data) @classmethod def retrieve( cls: Type[T], entity: Entity, custom_unit_id: int ) -> T: target = UrlTarget.from_integer(cls._URL_KEY, custom_unit_id) parameters = UrlParameters(entity_id=entity.id_, targets=[target]) request = ApiRequest( path=cls._PATH, method=HTTPMethod.GET, data=None, url_parameters=parameters, credentials=entity.session ) return cls._decode(entity, request.response_data) @classmethod def _retrieve( cls: Type[T], entity: Entity, custom_unit_ids: List[int] ) -> T: if not isinstance(entity, Entity): raise TypeError('entity must be of type `Entity`') key = CustomUnit._URL_KEY targets = [UrlTarget(key, str(i)) for i in custom_unit_ids] url_parameters = UrlParameters( entity_id=entity.id_, targets=targets ) request = ApiRequest( path=CustomUnit._PATH, method=HTTPMethod.GET, credentials=entity.session, data=None, url_parameters=url_parameters ) unit = cls._decode( entity, request.response_data ) return unit @classmethod def _decode( cls: Type[T], entity: Entity, data: Any ) -> T: return cls._decodeMany(entity, data)[0] @classmethod def _decodeMany( cls: Type[T], entity: Entity, data: Any ) -> List[T]: assert isinstance(entity, Entity) if not isinstance(data, list): raise ApiError('Unexpected non-list data returned') if len(data) < 1: raise ApiError('Unexpected empty response data') def decode(data: dict) -> T: if not isinstance(data, dict): raise ApiError('Unexpected non-dict data returned') try: unit = cls( entity=entity, id_=data['custom_unit_id'], code=data['code'], name=data['name'], priority=data['priority'], description=data['description'], exponent=data['exponent'] ) except KeyError as error: message = 'Expected key "{key}" missing from response data' message.format(key=error.args[0]) raise ApiError(message) return unit units = [decode(u) for u in data] return units def update( self, name: Optional[str] = None, code: Optional[str] = None, priority: Optional[int] = None, description: Optional[str] = None, exponent: Optional[str] = None ) -> None: """ Replace existing Custom Unit data with supplied data. Parameters not supplied will default to existing values. """ if name is None: name = self.name if code is None: code = self.code if priority is None: priority = self.priority if description is None: description = self.description if exponent is None: exponent = self.exponent assert isinstance(code, str) assert isinstance(priority, int) assert isinstance(description, str) assert isinstance(exponent, int) arguments = CustomUnit.UpdateArguments( self.id_, code, name, exponent, priority, description ) parameters = UrlParameters(entity_id=self.entity.id_) request = ApiRequest( path=self._PATH, method=HTTPMethod.PUT, data=DataPackage.from_object(arguments), url_parameters=parameters, credentials=self.entity.session ) return CustomUnit._decode( self.entity, request.response_data ) def delete( self, custom_unit_replacement: T = None, global_unit_replacement: GlobalUnit = None ) -> None: """ Irrecoverably delete this Custom Unit. Supply either a Custom Unit or Global Unit with which to replace any instances of this Custom Unit presently denominating an Account. Even if you are certain that this Custom Unit does not presently denominated any Account, you must supply a replacement unit. """ raise NotImplementedError class CreationArguments(Encodable): """ Used by instances of class CustomUnit to validate arguments provided for the creation of a new Custom Unit """ def __init__( self, code: str, name: str, exponent: int, priority: Optional[int] = None, description: Optional[str] = None ) -> None: super().__init__() self._code = CustomUnit._Code(code) self._name = CustomUnit._Name(name) self._priority = CustomUnit._Priority(priority) self._description = CustomUnit._Description(description) self._exponent = CustomUnit._Exponent(exponent) return def serialise(self) -> dict: data = { 'code': self._code.serialise(), 'name': self._name.serialise(), 'priority': self._priority.serialise(), 'description': self._description.serialise(), 'exponent': self._exponent.serialise() } return data class UpdateArguments(Encodable): def __init__( self, custom_unit_id: int, code: str, name: str, exponent: int, priority: Optional[int] = None, description: Optional[str] = None ) -> None: if not isinstance(custom_unit_id, int): raise TypeError('custom_unit_id must be of type `int`') self._id = custom_unit_id self._code = CustomUnit._Code(code) self._name = CustomUnit._Name(name) self._priority = CustomUnit._Priority(priority) self._description = CustomUnit._Description(description) self._exponent = CustomUnit._Exponent(exponent) return def serialise(self) -> Dict[str, Any]: data = { 'custom_unit_id': self._id, 'code': self._code.serialise(), 'name': self._name.serialise(), 'priority': self._priority.serialise(), 'description': self._description.serialise(), 'exponent': self._exponent.serialise() } return data class _Priority(Encodable): def __init__(self, priority: Optional[int]) -> None: self._priority = priority if priority is None: return self._priority = ConstrainedInteger( priority, 'priority', CustomUnit.MAX_PRIORITY_VALUE, CustomUnit.MIN_PRIORITY_VALUE ) return def serialise(self) -> Optional[int]: if self._priority is None: return None assert isinstance(self._priority, ConstrainedInteger) return self._priority.serialise() class _Code(ConstrainedString): def __init__(self, code: str) -> None: super().__init__( code, 'code', CustomUnit.MAX_CODE_LENGTH, CustomUnit.MIN_CODE_LENGTH ) return class _Name(ConstrainedString): def __init__(self, name: str) -> None: super().__init__( name, 'name', CustomUnit.MAX_NAME_LENGTH ) return class _Description(ConstrainedString): def __init__(self, description: Optional[str]) -> None: if description is None: description = '' super().__init__( description, 'description', CustomUnit.MAX_DESCRIPTION_LENGTH ) return class _Exponent(ConstrainedInteger): def __init__(self, exponent: int) -> None: super().__init__( exponent, 'exponent', CustomUnit.MAX_EXPONENT_VALUE, CustomUnit.MIN_EXPONENT_VALUE ) return def __repr__(self) -> str: rep = '<amatino.CustomUnit at {memory}, id: {id_}, code: {code}, ' rep += 'name: {name}, priority: {priority}, exponent: {exponent}, ' rep += 'description: {description}, session: {session_id}, ' rep += 'entity: {entity_id}>' representation = rep.format( memory=hex(id(self)), id_=str(self.id_), code=self.code, name=self.name, priority=self.priority, exponent=self.exponent, description=self.description, session_id=str(self.entity.session.id_), entity_id=self.entity.id_ ) return representation
class Ledger(Sequence, Denominated): """ A Ledger is a list of Transactions from the perspective of a particular Account. Ledgers are ordered by Transaction time, and include a running Account Balance for every line. You can request Ledgers in arbitrary Global or Custom Units, not just the native unit of the target Account. If you request a Ledger in a unit other than the target Account native unit, Amatino will compute and return unrealised gains and losses. Amatino will return a maximum total of 1,000 Ledger Rows per retrieval request. If the Ledger you define spans more than 1,000 rows, it will be broken into pages you can retrieve seperately. """ _PATH = '/accounts/ledger' def __init__(self, entity: Entity, account_id: int, start_time: AmatinoTime, end_time: AmatinoTime, recursive: bool, generated_time: AmatinoTime, global_unit_id: Optional[int], custom_unit_id: Optional[int], page: int, number_of_pages: int, order: LedgerOrder, ledger_rows: List[LedgerRow]) -> None: self._entity = entity self._account_id = account_id self._start_time = start_time self._end_time = end_time self._recursive = recursive self._generated_time = generated_time self._global_unit_id = global_unit_id self._custom_unit_id = custom_unit_id self._page = page self._number_of_pages = number_of_pages self._order = order self._rows = ledger_rows return session = Immutable(lambda s: s._entity.session) entity = Immutable(lambda s: s._entity) account_id = Immutable(lambda s: s._account_id) account = Immutable( lambda s: Account.retrieve(s.session, s.entity, s.account_id)) start_time = Immutable(lambda s: s._start_time.raw) end_time = Immutable(lambda s: s._end_time.raw) recursive = Immutable(lambda s: s._recursive) generated_time = Immutable(lambda s: s._generated_time.raw) global_unit_id = Immutable(lambda s: s._global_unit_id) custom_unit_id = Immutable(lambda s: s._custom_unit_id) page = Immutable(lambda s: s._page) number_of_pages = Immutable(lambda s: s._number_of_pages) order = Immutable(lambda s: s._order) rows = Immutable(lambda s: s._rows) @classmethod def retrieve(cls: Type[T], entity: Entity, account: Account, order: LedgerOrder = LedgerOrder.YOUNGEST_FIRST, page: int = 1, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, denomination: Optional[Denomination] = None) -> T: """ Retrieve a Ledger for the supplied account. Optionally specify order, page, denomination, start time, and end time. """ if not isinstance(entity, Entity): raise TypeError('entity must be of type `Entity`') arguments = Ledger.RetrieveArguments(account, order, page, start_time, end_time, denomination) data = DataPackage(object_data=arguments, override_listing=True) parameters = UrlParameters(entity_id=entity.id_) request = ApiRequest(path=cls._PATH, method=HTTPMethod.GET, credentials=entity.session, data=data, url_parameters=parameters) return cls._decode(entity, request.response_data) @classmethod def _decode(cls: Type[T], entity: Entity, data: Any) -> T: if not isinstance(data, dict): raise UnexpectedResponseType(type(data), dict) try: ledger = cls(entity=entity, account_id=data['account_id'], start_time=AmatinoTime.decode(data['start_time']), end_time=AmatinoTime.decode(data['end_time']), recursive=data['recursive'], generated_time=AmatinoTime.decode( data['generated_time']), global_unit_id=data['global_unit_denomination'], custom_unit_id=data['custom_unit_denomination'], ledger_rows=Ledger._decode_rows(data['ledger_rows']), page=data['page'], number_of_pages=data['number_of_pages'], order=LedgerOrder(data['ordered_oldest_first'])) except KeyError as error: raise MissingKey(error.args[0]) return ledger @classmethod def _decode_rows(cls: Type[T], rows: List[Any]) -> List[LedgerRow]: """Return LedgerRows decoded from raw API response data""" if not isinstance(rows, list): raise UnexpectedResponseType(rows, list) def decode(data) -> LedgerRow: if not isinstance(data, list): raise UnexpectedResponseType(data, list) row = LedgerRow(transaction_id=data[0], transaction_time=AmatinoTime.decode(data[1]), description=data[2], opposing_account_id=data[3], opposing_account_name=data[4], debit=AmatinoAmount.decode(data[5]), credit=AmatinoAmount.decode(data[6]), balance=AmatinoAmount.decode(data[7])) return row return [decode(r) for r in rows] class RetrieveArguments(Encodable): def __init__(self, account: Account, order: LedgerOrder = LedgerOrder.YOUNGEST_FIRST, page: int = 1, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, denomination: Optional[Denomination] = None) -> None: if not isinstance(account, Account): raise TypeError('account must be of type `Account`') if not isinstance(order, LedgerOrder): raise TypeError('order must beof type `LedgerOrder`') if not isinstance(page, int): raise TypeError('page must be of type `int`') if start_time and not isinstance(start_time, datetime): raise TypeError( 'start_time must be of type `datetime` or None') if end_time and not isinstance(end_time, datetime): raise TypeError('end_time must be of type `datetime` or None') if denomination and not isinstance(denomination, Denomination): raise TypeError( 'denomination must be of type `Denomination` or None') if denomination is None: denomination = account.denomination self._account = account self._order = order self._page = page self._start_time = None if start_time: self._start_time = AmatinoTime(start_time) self._end_time = None if end_time: self._end_time = AmatinoTime(end_time) self._denomination = denomination def serialise(self) -> Dict[str, Any]: global_unit_id = None custom_unit_id = None if isinstance(self._denomination, GlobalUnit): global_unit_id = self._denomination.id_ else: assert isinstance(self._denomination, CustomUnit) custom_unit_id = self._denomination.id_ start_time = None if self._start_time: start_time = AmatinoTime(self._start_time).serialise() end_time = None if self._end_time: end_time = AmatinoTime(self._end_time).serialise() data = { 'account_id': self._account.id_, 'start_time': start_time, 'end_time': end_time, 'page': self._page, 'global_unit_denomination': global_unit_id, 'custom_unit_denomination': custom_unit_id, 'order_oldest_first': self._order.value } return data def __iter__(self): return Ledger.Iterator(self._rows) class Iterator: """An iterator for iterating through LedgerRows in a Ledger""" def __init__(self, rows: List[LedgerRow]) -> None: self._index = 0 self._rows = rows return def __next__(self) -> LedgerRow: if self._index >= len(self._rows): raise StopIteration row = self._rows[self._index] self._index += 1 return row def __len__(self): return len(self.rows) def __getitem__(self, key): return self.rows[key]
class User: """ A User is a human producer and consumer of data stored by Amatino. When you create an Amatino account on the Amatino website, a User is generated in your name. You can create other Users at will to serve the needs of your application. For example, you might wish to create an Amatino User to associate with each end-user of your application, in order to link financial information stored in Amatino with that end-user. Users created via the Amatino API cannot login or otherwise interact with the amatino.io website in any way. They are not eligbile to receive customer support from us directly (though you are most welcome to request customer support to assist you with users you create), and don't generate associated discussion forum accounts. You have absolute control over their lifecycle. They can make requests to the Amatino API on their own behalf. Generally, if you are creating User accounts for your fellow developers, you will want to do so in your billing dashboard. Doing so will allow them to manage their password, post to the discussion forums, and contact us for support. If you are creating Users to manage financial data inside your application, you will want to do so via the Amatino API. Users and Entities are woven together using permission graphs. Any User may be granted read and or write access to any Account in any Entity, whether they were created in the billing dashboard or via the Amatino API. If you are on a Fixed Price plan, each additional user you create in the Amatino API will count towards your monthly bill. If you are on a Pay Per Use plan, creating additional Users incurs no direct marginal cost. You can change your plan at any time. """ _URL_KEY = 'user_id' _PATH = '/users' def __init__( self, session: Session, id_: int, email: Optional[str], name: Optional[str], handle: Optional[str], avatar_url: Optional[str] ) -> None: self._id = id_ self._email = email self._name = name self._handle = handle self._avatar_url = avatar_url self._session = session return id_ = Immutable(lambda s: s._id) email = Immutable(lambda s: s._email) name = Immutable(lambda s: s._name) handle = Immutable(lambda s: s._handle) avatar_url = Immutable(lambda s: s._avatar_url) def delete(self) -> None: """Return None after deleting this User""" target = UrlTarget.from_integer('user_id', self._id) parameters = UrlParameters(targets=[target]) ApiRequest( path=self._PATH, data=None, credentials=self._session, method=HTTPMethod.DELETE, url_parameters=parameters ) return @classmethod def retrieve_authenticated_user(cls: Type[T], session: Session) -> T: """Return the User authenticated by the supplied Session""" raise NotImplementedError # Pending bug fix in User retrieval @classmethod def retrieve(cls: Type[T], session: Session, id_: int) -> T: """Return the User with supplied ID""" if not isinstance(id_, int): raise TypeError('id_ must be of type `int`') return cls.retrieve_many(session, [id_])[0] @classmethod def retrieve_many( cls: Type[T], session: Session, ids: List[int] ) -> List[T]: """Return a list of Users""" if not isinstance(ids, list) or False in [ isinstance(i, int) for i in ids ]: raise TypeError('ids must be of type List[int]') return cls._retrieve_many(session, ids) @classmethod def _retrieve_many( cls: Type[T], session: Session, ids: Optional[List[int]] = None ) -> List[T]: """Return a list of users""" parameters = None if ids is not None: targets = UrlTarget.from_many_integers(cls._URL_KEY, ids) parameters = UrlParameters.from_targets(targets) request = ApiRequest( path=cls._PATH, data=None, credentials=session, method=HTTPMethod.GET, url_parameters=parameters ) users = cls.decode_many(session, request.response_data) return users @classmethod def decode(cls: Type[T], session: Session, data: Any) -> T: return cls.decode_many(session, [data])[0] @classmethod def decode_many(cls: Type[T], session: Session, data: Any) -> List[T]: """Return a list of Users decoded from API response data""" if not isinstance(data, list): raise UnexpectedResponseType(data, list) def decode(obj: Any) -> T: if not isinstance(obj, dict): raise UnexpectedResponseType(obj, dict) user = cls( session=session, id_=obj['user_id'], email=obj['email'], name=obj['name'], handle=obj['handle'], avatar_url=obj['avatar_url'] ) return user return [decode(u) for u in data] @classmethod def create_many( cls: Type[T], session: Session, arguments: List[K] ) -> List[T]: """Return many newly created Users""" if not isinstance(session, Session): raise TypeError('session must be of type Session') if not isinstance(arguments, list): raise TypeError( 'arguments must be of type List[User.CreateArguments]' ) if False in [isinstance(a, User.CreateArguments) for a in arguments]: raise TypeError( 'arguments must be of type List[User.CreateArguments]' ) request = ApiRequest( path=cls._PATH, method=HTTPMethod.POST, credentials=session, data=DataPackage(list_data=arguments), url_parameters=None ) return cls.decode_many(session, request.response_data) @classmethod def create( cls: Type[T], session: Session, secret: str, name: Optional[str] = None, handle: Optional[str] = None ) -> T: """Return a newly created User""" arguments = User.CreateArguments( secret=secret, name=name, handle=handle ) return cls.create_many(session, [arguments])[0] class CreateArguments(Encodable): def __init__( self, secret: str, name: Optional[str] = None, handle: Optional[str] = None ) -> None: if not isinstance(secret, str): raise TypeError('secret must be of type str') if len(secret) < 12 or len(secret) > 100: raise ValueError('secret must be >= 12, <= 100 characters long') if 'password' in secret: raise ValueError('secret cannot contain "password"') if name is not None and not isinstance(name, str): raise TypeError('If supplied, name must be str') if name is not None and len(name) > 512: raise ValueError('Max name length 512 characters') if handle is not None and not isinstance(handle, str): raise TypeError('If supplied, handle must be str') if handle is not None and len(handle) > 512: raise ValueError('Max handle length 512 characters') self._secret = secret self._name = name self._handle = handle return def serialise(self) -> Dict[str, Any]: return { 'secret': self._secret, 'name': self._name, 'handle': self._handle }
class ApiRequest: """ Private - Not intended to be used directly. An instance of an http request to the Amatino API. """ _ENDPOINT = 'https://api.amatino.io' _DEBUG_ENDPOINT = 'http://127.0.0.1:5000' _TIMEOUT = 10 def __init__(self, path: str, method: HTTPMethod, credentials: Optional[Credentials] = None, data: Optional[DataPackage] = None, url_parameters: Optional[UrlParameters] = None, debug: bool = False) -> None: self._response_data = None if credentials is not None: assert isinstance(credentials, Credentials) if data is not None: assert isinstance(data, DataPackage) request_data = data.as_json_bytes() else: request_data = None if url_parameters is not None: assert isinstance(url_parameters, UrlParameters) if debug is True or '--amatino-debug' in sys.argv[1:]: url = self._DEBUG_ENDPOINT else: url = self._ENDPOINT url += path if url_parameters is not None: url += url_parameters.parameter_string() headers = RequestHeaders(path, credentials, data) request = Request( url=url, data=request_data, headers=headers.dictionary(), method=method.value, ) try: self._response = urlopen(request, timeout=self._TIMEOUT) except HTTPError as error: if error.code == 404: raise ResourceNotFound raise error self._response_data = loads(self._response.read().decode('utf-8')) return response_data = Immutable(lambda s: s._response_data)
class BalanceProtocol(Denominated): """ Abstract class defining a protocol for classes representing balances. In practice these are Balance and RecrusiveBalance. This class is intended to be private, and internal to the Amatino library. You should not use it directly when integrating Amatino Python into your application. """ PATH = NotImplemented def __init__(self, entity: Entity, balance_time: AmatinoTime, generated_time: AmatinoTime, recursive: bool, global_unit_id: Optional[int], custom_unit_id: Optional[int], account_id: int, magnitude: Decimal) -> None: if self.PATH == NotImplemented: raise RuntimeError('Balance classes must implement .PATH property') assert isinstance(entity, Entity) assert isinstance(balance_time, AmatinoTime) assert isinstance(generated_time, AmatinoTime) assert isinstance(recursive, bool) if global_unit_id is not None: assert isinstance(global_unit_id, int) if custom_unit_id is not None: assert isinstance(custom_unit_id, int) assert isinstance(account_id, int) assert isinstance(magnitude, Decimal) self._entity = entity self._balance_time = balance_time self._generated_time = generated_time self._recursive = recursive self._custom_unit_id = custom_unit_id self._global_unit_id = global_unit_id self._magnitude = magnitude self._account_id = account_id return entity = Immutable(lambda s: s._entity) session = Immutable(lambda s: s._entity.session) time = Immutable(lambda s: s._balance_time.raw) generated_time = Immutable(lambda s: s._generated_time.raw) magnitude = Immutable(lambda s: s._magnitude) is_recursive = Immutable(lambda s: s._recursive) account_id = Immutable(lambda s: s._account_id) account = Immutable( lambda s: Account.retrieve(s.entity.session, s.entity, s.account_id)) global_unit_id = Immutable(lambda s: s._global_unit_id) custom_unit_id = Immutable(lambda s: s._custom_unit_id) @classmethod def retrieve_many(cls: Type[T], entity: Entity, arguments: List[K]) -> List[T]: """Retrieve several Balances.""" if not isinstance(entity, Entity): raise TypeError('entity must be of type `Entity`') if False in [isinstance(a, cls.RetrieveArguments) for a in arguments]: raise TypeError( 'arguments must be of type List[Balance.RetrieveArguments]') data = DataPackage(list_data=arguments) parameters = UrlParameters(entity_id=entity.id_) request = ApiRequest(path=cls.PATH, method=HTTPMethod.GET, credentials=entity.session, data=data, url_parameters=parameters) return cls._decode_many(entity, request.response_data) @classmethod def _decode_many(cls: Type[T], entity: Entity, data: Any) -> List[T]: if not isinstance(data, list): raise UnexpectedResponseType(data, list) for balance in data: if not isinstance(balance, dict): raise UnexpectedResponseType(balance, dict) balances = list() for balance in data: balances.append( cls(entity, AmatinoTime.decode(balance['balance_time']), AmatinoTime.decode(balance['generated_time']), balance['recursive'], balance['global_unit_denomination'], balance['custom_unit_denomination'], balance['account_id'], Decimal(balance['balance']))) return balances @classmethod def retrieve(cls: Type[T], entity: Entity, account: Account, balance_time: Optional[datetime] = None, denomination: Optional[Denomination] = None) -> T: """Retrieve a Balance""" arguments = cls.RetrieveArguments(account, balance_time, denomination) return cls.retrieve_many(entity, [arguments])[0] class RetrieveArguments(Encodable): def __init__(self, account: Account, balance_time: Optional[datetime] = None, denomination: Optional[Denomination] = None) -> None: if not isinstance(account, Account): raise TypeError('account must be of type Account') if (balance_time is not None and not isinstance(balance_time, datetime)): raise TypeError( 'balance_time must be of type `datetime or None') if (denomination is not None and not isinstance(denomination, Denomination)): raise TypeError('denomination must conform to `Denomination`') if denomination is None: denomination = account.denomination self._account = account self._balance_time = None if balance_time: self._balance_time = AmatinoTime(balance_time) self._denomination = denomination def serialise(self) -> Dict[str, Any]: global_unit_id = None custom_unit_id = None if isinstance(self._denomination, GlobalUnit): global_unit_id = self._denomination.id_ else: assert isinstance(self._denomination, CustomUnit) custom_unit_id = self._denomination.id_ balance_time = None if self._balance_time: balance_time = self._balance_time.serialise() data = { 'account_id': self._account.id_, 'balance_time': balance_time, 'custom_unit_denomination': custom_unit_id, 'global_unit_denomination': global_unit_id } return data
class TreeNode(Decodable): """ A Tree Node is a specialised view of an Account. It provides a recursive and individual balance for an Account. You will never interact with Tree Nodes' directly, instead you will receive lists of Tree Nodes as components of requests for Trees, Positions, and Performances. Tree Nodes are recursive objects that may contain other Tree Nodes, depending on whether the Account in question has any children. Trees, Positions, and Performances describe entire Entities. The User from whose perspective you retrieve a Tree, Position, or Performance may not have read access to every Account in the Entity. In such cases, Tree Nodes describing Accounts to which a User does not have read access will be returned with null in their balance fields, and a generic Type in place of the actual Account name. """ def __init__( self, entity: Entity, account_id: int, depth: int, account_balance: Decimal, recursive_balance: Decimal, name: str, am_type: AMType, children: Optional[List[T]] ) -> None: assert isinstance(entity, Entity) assert isinstance(account_id, int) assert isinstance(depth, int) assert isinstance(account_balance, Decimal) assert isinstance(recursive_balance, Decimal) assert isinstance(name, str) assert isinstance(am_type, AMType) if children is not None: assert isinstance(children, list) assert False not in [isinstance(c, TreeNode) for c in children] self._entity = entity self._account_id = account_id self._depth = depth self._account_balance = account_balance self._recursive_balance = recursive_balance self._name = name self._am_type = am_type self._children = children return session = Immutable(lambda s: s._entity.session) entity = Immutable(lambda s: s._entity) account_id = Immutable(lambda s: s._account_id) depth = Immutable(lambda s: s._depth) account_balance = Immutable(lambda s: s._account_balance) recursive_balance = Immutable(lambda s: s._recursive_balance) name = Immutable(lambda s: s._name) am_type = Immutable(lambda s: s._am_type) children = Immutable(lambda s: s._children) has_children = Immutable( lambda s: s._children is not None and len(s._children) > 0 ) account = Immutable(lambda s: s._account()) _node_cached_account = None def _account(self) -> Account: """ Return the Account this TreeNode describes. Cache it for repeated requests. """ if isinstance(self._node_cached_account, Account): return self._node_cached_account account = Account.retrieve( session=self.entity.session, entity=self.entity, account_id=self.account_id ) self._node_cached_account = account return account @classmethod def decode(cls: Type[T], entity: Entity, data: Any) -> T: if not isinstance(data, dict): raise UnexpectedResponseType(data, dict) try: children = None if data['children'] is not None: children = [cls.decode(entity, d) for d in data['children']] node = cls( entity=entity, account_id=data['account_id'], depth=data['depth'], account_balance=AmatinoAmount.decode( data['account_balance'] ), recursive_balance=AmatinoAmount.decode( data['recursive_balance'] ), name=data['name'], am_type=AMType(data['type']), children=children ) except KeyError as error: raise MissingKey(error.args[0]) return node
class Performance(Denominated, Decodable): """ A Performance is a hierarchical collection of Account balances describing the financial performance of an Entity over a period of time. They are generic representations of popular accounting constructs known as the 'Income Statement', 'Profit & Loss', or 'Statement of Financial Performance'. The Peformance object is jurisdiction agnostic, and obeys simple double-entry accounting rules. They list income and expense, each nesting its own children. You can retrieve a Performance denominated in an arbitrary Global Unit or Custom Unit. Amatino will automatically calculate the implicit gain or loss relative to each Account's underlying denomination and include those gains and losses in each Account balance. A Performance may be retrieved to an arbitrary depth. Depth in the Performance context is the number of levels down the Account hierarchy Amatino should go when retrieving the Performance. For example, if a top-level Account has child accounts three layers deep, then specifying a depth of three will retrieve all those children. Regardless of the depth you specify, Amatino will calculate recursive balances at full depth. """ _PATH = '/performances' def __init__( self, entity: Entity, start_time: AmatinoTime, end_time: AmatinoTime, generated_time: AmatinoTime, global_unit_id: Optional[int], custom_unit_id: Optional[int], income: List[TreeNode], expenses: List[TreeNode], depth: int ) -> None: assert isinstance(entity, Entity) assert isinstance(start_time, AmatinoTime) assert isinstance(end_time, AmatinoTime) assert isinstance(generated_time, AmatinoTime) if global_unit_id is not None: assert isinstance(global_unit_id, int) if custom_unit_id is not None: assert isinstance(custom_unit_id, int) assert isinstance(income, list) assert False not in [isinstance(i, TreeNode) for i in income] assert isinstance(expenses, list) assert False not in [isinstance(e, TreeNode) for e in expenses] assert isinstance(depth, int) self._entity = entity self._start_time = start_time self._end_time = end_time self._generated_time = generated_time self._global_unit_id = global_unit_id self._custom_unit_id = custom_unit_id self._income = income self._expenses = expenses self._depth = depth return entity = Immutable(lambda s: s._entity) session = Immutable(lambda s: s._entity.session) start_time = Immutable(lambda s: s._start_time.raw) end_time = Immutable(lambda s: s._end_time.raw) generated_time = Immutable(lambda s: s._generated_time.raw) custom_unit_id = Immutable(lambda s: s._custom_unit_id) global_unit_id = Immutable(lambda s: s._global_unit_id) income = Immutable(lambda s: s._income) expenses = Immutable(lambda s: s._expenses) has_income = Immutable(lambda s: len(s._income) > 0) has_expenses = Immutable(lambda s: len(s._expenses) > 0) total_income = Immutable(lambda s: s._compute_income()) total_expenses = Immutable(lambda s: s._compute_expenses()) @classmethod def decode( cls: Type[T], entity: Entity, data: Any ) -> T: if not isinstance(data, dict): raise UnexpectedResponseType(data, dict) try: income = None if data['income'] is not None: income = TreeNode.decode_many(entity, data['income']) expenses = None if data['expenses'] is not None: expenses = TreeNode.decode_many(entity, data['expenses']) performance = cls( entity=entity, start_time=AmatinoTime.decode(data['start_time']), end_time=AmatinoTime.decode(data['end_time']), generated_time=AmatinoTime.decode(data['generated_time']), custom_unit_id=data['custom_unit_denomination'], global_unit_id=data['global_unit_denomination'], income=income, expenses=expenses, depth=data['depth'] ) except KeyError as error: raise MissingKey(error.args[0]) return performance @classmethod def retrieve( cls: Type[T], entity: Entity, start_time: datetime, end_time: datetime, denomination: Denomination, depth: Optional[int] = None ) -> T: arguments = cls.RetrieveArguments( start_time=start_time, end_time=end_time, denomination=denomination, depth=depth ) return cls._retrieve(entity, arguments) @classmethod def _retrieve(cls: Type[T], entity: Entity, arguments: K) -> T: """Retrieve a Performance""" if not isinstance(entity, Entity): raise TypeError('entity must be of type `Entity`') if not isinstance(arguments, cls.RetrieveArguments): raise TypeError( 'arguments must be of type Performance.RetrieveArguments' ) data = DataPackage(object_data=arguments, override_listing=True) parameters = UrlParameters(entity_id=entity.id_) request = ApiRequest( path=cls._PATH, method=HTTPMethod.GET, credentials=entity.session, data=data, url_parameters=parameters ) return cls.decode(entity, request.response_data) def _compute_income(self) -> Decimal: """Return total income""" if not self.has_income: return Decimal(0) income = sum([i.recursive_balance for i in self._income]) assert isinstance(income, Decimal) return income def _compute_expenses(self) -> Decimal: """Return total expenses""" if not self.has_expenses: return Decimal(0) expenses = sum([e.recursive_balance for e in self._expenses]) assert isinstance(expenses, Decimal) return expenses class RetrieveArguments(Encodable): def __init__( self, start_time: datetime, end_time: datetime, denomination: Denomination, depth: Optional[int] = None ) -> None: if not isinstance(start_time, datetime): raise TypeError('start_time must be of type `datetime`') if not isinstance(end_time, datetime): raise TypeError('end_time must be of type `datetime`') if not isinstance(denomination, Denomination): raise TypeError('denomination must be of type `Denomination`') if depth is not None and not isinstance(depth, int): raise TypeError( 'If supplied, depth must be of type `int`' ) self._start_time = AmatinoTime(start_time) self._end_time = AmatinoTime(end_time) self._denomination = denomination self._depth = depth return def serialise(self) -> Dict[str, Any]: global_unit_id = None custom_unit_id = None if isinstance(self._denomination, GlobalUnit): global_unit_id = self._denomination.id_ else: assert isinstance(self._denomination, CustomUnit) custom_unit_id = self._denomination.id_ data = { 'start_time': self._start_time.serialise(), 'end_time': self._end_time.serialise(), 'custom_unit_denomination': custom_unit_id, 'global_unit_denomination': global_unit_id, 'depth': self._depth } return data
class Color: """ A representation of an RGB color. """ def __init__(self, red: int, green: int, blue: int) -> None: if (not isinstance(red, int) or not isinstance(green, int) or not isinstance(blue, int)): raise TypeError('Color components must be of type int') if (red < 0 or red > 255 or green < 0 or green > 255 or blue < 0 or blue > 255): raise ValueError('Color components must be >= 0 and <= 255') self._red = red self._green = green self._blue = blue return red = Immutable(lambda s: s._red) green = Immutable(lambda s: s._green) blue = Immutable(lambda s: s._blue) hex_string = Immutable(lambda s: s.as_hex_string()) @classmethod def from_hex_string(cls: Type[T], hexstring: str) -> T: if not isinstance(hexstring, str): raise TypeError('hexstring must be of type `str`') if len(hexstring) != 6: raise ValueError('hexstring must be 6 characters') red = int(hexstring[:2], 16) green = int(hexstring[2:4], 16) blue = int(hexstring[4:6], 16) color = cls(red=red, green=green, blue=blue) return color def as_hex_string(self) -> str: """ Return the colour as a hex value string """ hex_string = hex(self.red)[2:] hex_string += hex(self.green)[2:] hex_string += hex(self.blue)[2:] return hex_string def as_int_tuple(self) -> tuple: """ Return the colour as a tuple of three integers, in the order (red, green, blue) """ return (self._red, self._green, self._blue) def serialise(self) -> str: assert len(self.hex_string) == 6 return self.hex_string
class TransactionVersionList(Sequence): """ Amatino retains a version history of every Transaction. That history allows you to step backwards and forwards through changes to the accounting information describing an Entity. To view the history of a Transaction, you can retrieve a Transaction Version List. """ _PATH = '/transactions/version/list' def __init__( self, entity: Entity, transaction_id: int, versions: List[Transaction] ) -> None: assert isinstance(entity, Entity) assert isinstance(transaction_id, int) assert isinstance(versions, list) if len(versions) > 0: assert False not in [isinstance(t, Transaction) for t in versions] self._entity = entity self._transaction_id = transaction_id self._versions = versions return versions = Immutable(lambda s: s._versions) entity = Immutable(lambda s: s._entity) session = Immutable(lambda s: s._entity.session) def __len__(self): return len(self.versions) def __getitem__(self, key): return self.versions[key] def __iter__(self): return TransactionVersionList.Iterator(self._versions) class Iterator: """An iterator for iterating through versions""" def __init__(self, versions: List[Transaction]) -> None: self._index = 0 self._versions = versions return def __next__(self) -> Transaction: if self._index >= len(self._versions): raise StopIteration version = self._versions[self._index] self._index += 1 return version @classmethod def retrieve( cls: Type[T], entity: Entity, transaction: Transaction ) -> T: """Return a TransactionVersionList for the supplied Transaction""" if not isinstance(transaction, Transaction): raise TypeError('transaction must be of type Transaction') if not isinstance(entity, Entity): raise TypeError('entity must be of type Entity') targets = [UrlTarget.from_integer('transaction_id', transaction.id_)] parameters = UrlParameters(entity_id=entity.id_, targets=targets) request = ApiRequest( path=cls._PATH, method=HTTPMethod.GET, credentials=entity.session, data=None, url_parameters=parameters ) return cls._decode(entity, request.response_data) @classmethod def _decode(cls: Type[T], entity: Entity, data: Any) -> T: if not isinstance(data, list): raise UnexpectedResponseType(data, list) if len(data) < 1: raise ApiError('Response unexpectedly empty') tx_list_data = data[0] try: if tx_list_data['versions'] is None: tx_list_data['versions'] = list() tx_list = cls( entity=entity, transaction_id=tx_list_data['transaction_id'], versions=Transaction.decode_many( entity, tx_list_data['versions'] ) ) except KeyError as error: raise MissingKey(error.args[0]) return tx_list
class Entry(Encodable): """ Entries compose Transactions. An individual entry allocates some value to an Account as either one of the fundamental Sides: a debit or a credit. All together, those debits and credits will add up to zero, satisfying the fundamental double-entry accounting equality. """ MAX_DESCRIPTION_LENGTH = 1024 def __init__( self, side: Side, amount: Decimal, account: Optional[Account] = None, description: Optional[str] = None, account_id: Optional[int] = None ) -> None: if not isinstance(side, Side): raise TypeError('side must be of type `Side`') if not isinstance(amount, Decimal): raise TypeError('amount must be of type `Decimal`') self._side = side if account_id is not None: assert isinstance(account_id, int) self._account_id = account_id else: if not isinstance(account, Account): raise TypeError('account must be of type `Account`') self._account_id = account.id_ self._amount = amount self._description = Entry._Description(description) return side = Immutable(lambda s: s._side) account_id = Immutable(lambda s: s._account_id) amount = Immutable(lambda s: s._amount) description = Immutable(lambda s: s._description) def serialise(self) -> Dict[str, Any]: data = { 'account_id': self._account_id, 'amount': str(self._amount), 'description': self._description.serialise(), 'side': self._side.value } return data class _Description(Encodable): def __init__(self, string: Optional[str]) -> None: if string is not None and not isinstance(string, str): raise TypeError('description must be of type `str` or None') if string is None: string = '' self._description = ConstrainedString( string, 'description', Entry.MAX_DESCRIPTION_LENGTH ) return def serialise(self) -> str: return str(self._description) @classmethod def create( cls: Type[T], side: Side, amount: Decimal, account: Account, description: Optional[str] = None ) -> T: return cls(side, amount, account=account, description=description) @classmethod def create_balanced_pair( cls: Type[T], debit_account: Account, credit_account: Account, amount: Decimal, description: Optional[str] = None ) -> List[T]: debit = cls(Side.debit, amount, debit_account, description) credit = cls(Side.credit, amount, credit_account, description) return [debit, credit] @classmethod def plug( cls: Type[T], account: Account, entries: List[T], description: Optional[str] = None ) -> Optional[T]: """ Return an entry plugging the balance gap in a given set of Entries. Or, return None if the Entries already balance. """ if False in [isinstance(e, Entry) for e in entries]: raise TypeError('Entries must be of type List[Entry]') debits = sum([e.amount for e in entries if e.side == Side.debit]) credits_ = sum([e.amount for e in entries if e.side == Side.credit]) if debits == credits_: return None if debits > credits_: plug_side = Side.credit amount = Decimal(debits - credits_) else: plug_side = Side.debit amount = Decimal(credits_ - debits) return cls(plug_side, amount, account, description)
class UserList(Sequence): """ A User List is a collection of Users for whom the retrieving User has billing responsibility, and who were created using the Amatino API. The User List excludes Users managed by the billing dashboard. """ _PATH = '/users/list' def __init__(self, page: int, number_of_pages: int, generated_time: AmatinoTime, state: State, users: List[User], session: Session) -> None: assert isinstance(generated_time, AmatinoTime) assert isinstance(number_of_pages, int) assert isinstance(page, int) assert isinstance(users, list) assert False not in [isinstance(u, User) for u in users] assert isinstance(state, State) assert isinstance(session, Session) self._generated_time = generated_time self._number_of_pages = number_of_pages self._page = page self._users = users self._state = state self._session = session return generated_time = Immutable(lambda s: s._generated_time.raw) number_of_pages = Immutable(lambda s: s._number_of_pages) page = Immutable(lambda s: s._page) users = Immutable(lambda s: s._users) state = Immutable(lambda s: s._state) has_more_pages = Immutable(lambda s: s._number_of_pages > self._page) def __iter__(self): return UserList.Iterator(self._users) class Iterator: """An iterator for iterating through users in a UserList""" def __init__(self, users: List[User]) -> None: self._index = 0 self._users = users return def __next__(self) -> User: if self._index >= len(self._users): raise StopIteration user = self._users[self._index] self._index += 1 return user def __len__(self): return len(self.users) def __getitem__(self, key): return self.users[key] def next_page(self: T) -> Optional[T]: """ Return the next page available in this Entity List, or None if no more pages are available. """ if not self.has_more_pages: return None return self.retrieve(session=self._session, state=self._state, page=self._page + 1) @classmethod def retrieve(cls: Type[T], session: Session, state: State = State.ALL, page: int = 1) -> T: """ Retrieve a UserList from the perspective of the User tied to the supplied Session. Optionally specify a State (active, all, deleted) and integer page number. """ if not isinstance(session, Session): raise TypeError('session must be of type Session') if not isinstance(state, State): raise TypeError('state must be of type State') if not isinstance(page, int): raise TypeError('page must be of type int') state_target = UrlTarget('state', state.value) page_target = UrlTarget('page', str(page)) parameters = UrlParameters(targets=[state_target, page_target]) request = ApiRequest(path=cls._PATH, method=HTTPMethod.GET, credentials=session, data=None, url_parameters=parameters) return cls.decode(session, request.response_data) @classmethod def decode(cls: Type[T], session: Session, data: Any) -> T: if not isinstance(data, dict): raise ApiError('Unexpected data type returned: ' + str(type(data))) try: user_list = cls(page=data['page'], number_of_pages=data['number_of_pages'], generated_time=AmatinoTime.decode( data['generated_time']), state=State(data['state']), users=User.decode_many(session, data['users']), session=session) except KeyError as error: raise MissingKey(error.args[0]) return user_list
class Session(Credentials): """ Sessions are the keys to the Amatino kingdom. All requests to the Amatino API, except those requests to create Sessions themselves, must include two HTTP headers: An integer session identifier, and a Hashed Message Authentication Code (HMAC) signed with a Session API Key. The Session object handles said header construction and HMAC signing for you behind the scenes. Creating a new Session is analogous to 'logging in', and deleting a Session with the delete() method is analogous to 'logging out'. Your application might wish to create multiple Sessions for a User. For example, one per device. """ _PATH = '/session' def __init__(self, user_id: int = None, session_id: int = None, api_key: str = None) -> None: self._api_key = api_key self._session_id = session_id self._user_id = user_id return api_key = Immutable(lambda s: s._api_key) session_id = Immutable(lambda s: s._session_id) user_id = Immutable(lambda s: s._user_id) id_ = Immutable(lambda s: s.session_id) @classmethod def create_with_email(cls: Type[T], email: str, secret: str) -> T: """ Create a new session using an email / secret pair. """ if not isinstance(email, str): raise TypeError('email must be of type `str`') if not isinstance(secret, str): raise TypeError('secret must be of type `str') return cls._create(None, email, secret) @classmethod def create_with_user_id(cls: Type[T], user_id: int, secret: str) -> T: """ Create a new session using a user_id / secret pair """ if not isinstance(user_id, int): raise TypeError('user_id must be of type `int`') if not isinstance(secret, str): raise TypeError('secret must be of type `str`') return cls._create(user_id, None, secret) @classmethod def _create(cls: Type[T], user_id: Optional[int], email: Optional[str], secret: str) -> T: new_arguments = NewSessionArguments(secret, email, user_id) request_data = DataPackage(object_data=new_arguments, override_listing=True) request = ApiRequest(path=cls._PATH, method=HTTPMethod.POST, data=request_data) entity = cls._decode(request.response_data) return entity @classmethod def _decode(cls: Type[T], response_data: Any): if not isinstance(response_data, dict): raise ApiError('Unexpected non-dict type when decoding Session') try: api_key = response_data['api_key'] session_id = response_data['session_id'] user_id = response_data['user_id'] except KeyError as error: message = 'Expected key "{key}" missing from response data' message.format(key=error.args[0]) raise ApiError(message) entity = cls(user_id, session_id, api_key) return entity def delete(self): """ Destroy this Session, such that its id and api_key are no longer valid for authenticating Amatino API requests. Analagous to 'logging out' the underlying User. """ raise NotImplementedError
class Position(Denominated, Decodable): """ Positions are hierarchical collections of Account balances describing the financial position of an Entity at a point in time. They are generic representations of popular accounting constructs better known as a 'Balance Sheet', 'Statement of Financial Position', or 'Statements of Assets, Liabilities and Owner's Equity. Positions are jurisdiction agnostic, and obey simple double-entry accounting rules. They list asset, liability, and equity Accounts, each nesting its own children., You can retrieve Positions denominated in arbitrary Global Unit or Custom Unit. When a particular denomination gives rise to an unrealised gain or loss, Amatino will automatically calculate that gain or loss and include it as a top-level account in the returned Position. Positions may also be retrieved to an arbitrary depth. Depth in the Position context is the number of levels down the Account hierarchy Amatino should go when retrieving the Position. For example, if a top-level Account has child accounts three layers deep, then specifying a depth of three will retrieve all those children. Regardless of the depth you specify, Amatino will calculate recursive balances for all Accounts at full depth. """ _PATH = '/positions' def __init__( self, entity: Entity, balance_time: AmatinoTime, generated_time: AmatinoTime, global_unit_id: Optional[int], custom_unit_id: Optional[int], assets: List[TreeNode], liabilities: List[TreeNode], equities: List[TreeNode], depth: int ) -> None: assert isinstance(entity, Entity) assert isinstance(balance_time, AmatinoTime) assert isinstance(generated_time, AmatinoTime) if global_unit_id is not None: assert isinstance(global_unit_id, int) if custom_unit_id is not None: assert isinstance(custom_unit_id, int) assert isinstance(assets, list) assert False not in [isinstance(a, TreeNode) for a in assets] assert isinstance(liabilities, list) assert False not in [isinstance(l, TreeNode) for l in liabilities] assert isinstance(equities, list) assert False not in [isinstance(e, TreeNode) for e in equities] self._entity = entity self._balance_time = balance_time self._generated_time = generated_time self._global_unit_id = global_unit_id self._custom_unit_id = custom_unit_id self._assets = assets self._liabilities = liabilities self._equities = equities self._depth = depth return session = Immutable(lambda s: s._entity.session) entity = Immutable(lambda s: s._entity) balance_time = Immutable(lambda s: s._balance_time.raw) generated_time = Immutable(lambda s: s._generated_time.raw) custom_unit_id = Immutable(lambda s: s._custom_unit_id) global_unit_id = Immutable(lambda s: s._global_unit_id) assets = Immutable(lambda s: s._assets) liabilities = Immutable(lambda s: s._liabilities) equities = Immutable(lambda s: s._equities) has_assets = Immutable(lambda s: len(s._assets) > 0) has_liabilities = Immutable(lambda s: len(s._liabilities) > 0) has_equities = Immutable(lambda s: len(s._equities) > 0) total_assets = Immutable(lambda s: s._compute_total(s._assets)) total_liabilities = Immutable(lambda s: s._compute_total(s._liabilities)) total_equity = Immutable(lambda s: s._compute_total(s._equities)) def _compute_total(self, nodes: List[TreeNode]) -> Decimal: """Return the total of all top level recursive balances""" if len(nodes) < 1: return Decimal(0) total = sum([n.recursive_balance for n in nodes]) assert isinstance(total, Decimal) return total @classmethod def decode( cls: Type[T], entity: Entity, data: Any ) -> T: if not isinstance(data, dict): raise UnexpectedResponseType(data, dict) try: if data['assets'] is None: data['assets'] = list() if data['liabilities'] is None: data['liabilities'] = list() if data['equities'] is None: data['equities'] = list() position = cls( entity=entity, balance_time=AmatinoTime.decode(data['balance_time']), generated_time=AmatinoTime.decode(data['generated_time']), global_unit_id=data['global_unit_denomination'], custom_unit_id=data['custom_unit_denomination'], assets=TreeNode.decode_many(entity, data['assets']), liabilities=TreeNode.decode_many(entity, data['liabilities']), equities=TreeNode.decode_many(entity, data['equities']), depth=data['depth'] ) except KeyError as error: raise MissingKey(error.args[0]) return position @classmethod def retrieve( cls: Type[T], entity: Entity, balance_time: datetime, denomination: Denomination, depth: Optional[int] = None ) -> T: arguments = cls.RetrieveArguments( balance_time=balance_time, denomination=denomination, depth=depth ) return cls._retrieve(entity, arguments) @classmethod def _retrieve(cls: Type[T], entity: Entity, arguments: K) -> T: """Retrieve a Position""" if not isinstance(entity, Entity): raise TypeError('entity must be of type `Entity`') if not isinstance(arguments, cls.RetrieveArguments): raise TypeError( 'arguments must be of type Position.RetrieveArguments' ) data = DataPackage(object_data=arguments, override_listing=True) parameters = UrlParameters(entity_id=entity.id_) request = ApiRequest( path=cls._PATH, method=HTTPMethod.GET, credentials=entity.session, data=data, url_parameters=parameters ) return cls.decode(entity, request.response_data) class RetrieveArguments(Encodable): def __init__( self, balance_time: datetime, denomination: Denomination, depth: Optional[int] = None ) -> None: if not isinstance(balance_time, datetime): raise TypeError('balance_time must be of type `datetime`') if not isinstance(denomination, Denomination): raise TypeError('denomination must be of type `Denomination`') if depth is not None and not isinstance(depth, int): raise TypeError('If supplied, depth must be `int`') self._balance_time = AmatinoTime(balance_time) self._denomination = denomination self._depth = depth return def serialise(self) -> Dict[str, Any]: global_unit_id = None custom_unit_id = None if isinstance(self._denomination, GlobalUnit): global_unit_id = self._denomination.id_ else: assert isinstance(self._denomination, CustomUnit) custom_unit_id = self._denomination.id_ data = { 'balance_time': self._balance_time.serialise(), 'custom_unit_denomination': custom_unit_id, 'global_unit_denomination': global_unit_id, 'depth': self._depth } return data
class Transaction(Sequence): """ A Transaction is an exchange of value between two or more Accounts. For example, the raising of an invoice, the incrurring of a liability, or the receipt of a payment. Many Transactions together, each touching the same Account, form an Account Ledger, and the cumulative sum of the Transactions form an Account Balance. Transactions are composed of Entries, each of which includes a debit or credit (The fundamental Sides). The sum of all debits and credits in all the Entries that compose a Transaction must always equal zero. Transactions may be retrieved and created in arbitrary units, either a Global Units or Custom Units. Amatino will transparently handle all unit conversions. For example, a Transaction could be created in Australian Dollars, touch an Account denominated in Pounds Sterling, and be retrieved in Bitcoin. """ _PATH = '/transactions' MAX_DESCRIPTION_LENGTH = 1024 _URL_KEY = 'transaction_id' def __init__(self, entity: Entity, transaction_id: int, transaction_time: AmatinoTime, version_time: AmatinoTime, description: str, entries: List[Entry], global_unit_id: Optional[int] = None, custom_unit_id: Optional[int] = None) -> None: self._entity = entity self._id = transaction_id self._time = transaction_time self._version_time = version_time self._description = description self._entries = entries self._global_unit_id = global_unit_id self._custom_unit_id = custom_unit_id return session = Immutable(lambda s: s._entity.session) entity = Immutable(lambda s: s._entity) id_ = Immutable(lambda s: s._id) time = Immutable(lambda s: s._time.raw) version_time = Immutable(lambda s: s._version_time.raw) description = Immutable(lambda s: s._description) entries = Immutable(lambda s: s._entries) global_unit_id = Immutable(lambda s: s._global_unit_id) custom_unit_id = Immutable(lambda s: s._custom_unit_id) denomination = Immutable(lambda s: s._denomination()) magnitude = Immutable( lambda s: sum([e.amount for e in s._entries if e.side == Side.debit])) def __len__(self): return len(self.entries) def __getitem__(self, key): return self.entries[key] @classmethod def create( cls: Type[T], entity: Entity, time: datetime, entries: List[Entry], denomination: Denomination, description: Optional[str] = None, ) -> T: arguments = Transaction.CreateArguments(time, entries, denomination, description) data = DataPackage.from_object(arguments) parameters = UrlParameters(entity_id=entity.id_) request = ApiRequest(path=Transaction._PATH, method=HTTPMethod.POST, credentials=entity.session, data=data, url_parameters=parameters) transaction = cls._decode(entity, request.response_data) return transaction @classmethod def retrieve(cls: Type[T], entity: Entity, id_: int, denomination: Denomination) -> T: """Return a retrieved Transaction""" return cls.retrieve_many(entity, [id_], denomination)[0] @classmethod def retrieve_many(cls: Type[T], entity: Entity, ids: List[int], denomination: Denomination) -> List[T]: """Return many retrieved Transactions""" if not isinstance(entity, Entity): raise TypeError('entity must be of type `Entity`') if not isinstance(ids, list): raise TypeError('ids must be of type `list`') if False in [isinstance(i, int) for i in ids]: raise TypeError('ids must be of type `int`') parameters = UrlParameters(entity_id=entity.id_) data = DataPackage(list_data=[ cls.RetrieveArguments(i, denomination, None) for i in ids ]) request = ApiRequest(path=Transaction._PATH, method=HTTPMethod.GET, credentials=entity.session, data=data, url_parameters=parameters) transactions = cls.decode_many(entity, request.response_data) return transactions @classmethod def _decode(cls: Type[T], entity: Entity, data: List[dict]) -> T: return cls.decode_many(entity, data)[0] @classmethod def decode_many(cls: Type[T], entity: Entity, data: Any) -> List[T]: if not isinstance(data, list): raise ApiError('Unexpected non-list data returned') if len(data) < 1: raise ApiError('Unexpected empty response data') def decode(data: dict) -> T: if not isinstance(data, dict): raise ApiError('Unexpected non-dict data returned') try: transaction = cls( entity=entity, transaction_id=data['transaction_id'], transaction_time=AmatinoTime.decode( data['transaction_time']), version_time=AmatinoTime.decode(data['version_time']), description=data['description'], entries=cls._decode_entries(data['entries']), global_unit_id=data['global_unit_denomination'], custom_unit_id=data['custom_unit_denomination']) except KeyError as error: raise MissingKey(error.args[0]) return transaction transactions = [decode(t) for t in data] return transactions def update(self: T, time: Optional[datetime] = None, entries: Optional[List[Entry]] = None, denomination: Optional[Denomination] = None, description: Optional[str] = None) -> T: """Replace existing transaction data with supplied data.""" arguments = Transaction.UpdateArguments(self, time, entries, denomination, description) data = DataPackage.from_object(arguments) parameters = UrlParameters(entity_id=self.entity.id_) request = ApiRequest(path=Transaction._PATH, method=HTTPMethod.PUT, credentials=self.entity.session, data=data, url_parameters=parameters) transaction = Transaction._decode(self.entity, request.response_data) if transaction.id_ != self.id_: raise ApiError('Mismatched response Trasaction ID - Fatal') return transaction def delete(self) -> None: """ Destroy this Transaction, such that it will no longer be included in any view of this Entity's accounting information. """ target = UrlTarget(self._URL_KEY, str(self.id_)) parameters = UrlParameters(entity_id=self.entity.id_, targets=[target]) ApiRequest(path=self._PATH, method=HTTPMethod.DELETE, credentials=self.entity.session, data=None, url_parameters=parameters) return def restore(self) -> None: """ Restore this transaction from a deleted state to an active state. """ raise NotImplementedError def list_versions(self) -> List[Any]: """Return a list of versions of this Transaction""" raise NotImplementedError def _denomination(self) -> Denomination: """Return the Denomination of this Transaction""" if self.global_unit_id is not None: return GlobalUnit.retrieve(self.entity.session, self.global_unit_id) return CustomUnit.retrieve(self.entity, self.custom_unit_id) @classmethod def _decode_entries(cls: Type[T], data: Any) -> List[Entry]: """Return Entries decoded from API response data""" if not isinstance(data, list): raise ApiError('Unexpected API response type ' + str(type(data))) def decode(obj) -> Entry: if not isinstance(obj, dict): raise ApiError('Unexpected API object type ' + str(type(obj))) try: entry = Entry(side=Side(obj['side']), amount=AmatinoAmount.decode(obj['amount']), account_id=obj['account_id'], description=obj['description']) except KeyError as error: raise MissingKey(error.args[0]) return entry return [decode(e) for e in data] class RetrieveArguments(Encodable): def __init__(self, transaction_id: int, denomination: Denomination, version: Optional[int]) -> None: if not isinstance(transaction_id, int): raise TypeError('transaction_id must be of type `int`') if not isinstance(denomination, Denomination): raise TypeError('denomination must be of type `Denomination`') if version and not isinstance(version, int): raise TypeError('version must be of type `Optional[int]`') self._transaction_id = transaction_id if isinstance(denomination, GlobalUnit): self._global_unit_id = denomination.id_ self._custom_unit_id = None else: self._global_unit_id = None self._custom_unit_id = denomination.id_ self._version = version return def serialise(self) -> Dict[str, Any]: data = { 'transaction_id': self._transaction_id, 'global_unit_denomination': self._global_unit_id, 'custom_unit_denomination': self._custom_unit_id, 'version': self._version } return data class CreateArguments(Encodable): def __init__( self, time: datetime, entries: List[Entry], denomination: Denomination, description: Optional[str] = None, ) -> None: if not isinstance(time, datetime): raise TypeError('time must be of type `datetime.datetime`') if not isinstance(denomination, Denomination): raise TypeError('denomination must be of type `Denomination`') self._time = AmatinoTime(time) self._denomination = denomination self._entries = Transaction._Entries(entries) self._description = Transaction._Description(description) return def serialise(self) -> Dict[str, Any]: if isinstance(self._denomination, CustomUnit): custom_unit_id = self._denomination.id_ global_unit_id = None else: custom_unit_id = None global_unit_id = self._denomination.id_ data = { 'transaction_time': self._time.serialise(), 'custom_unit_denomination': custom_unit_id, 'global_unit_denomination': global_unit_id, 'description': self._description.serialise(), 'entries': self._entries.serialise() } return data class UpdateArguments(Encodable): def __init__(self, transaction: T, time: Optional[datetime] = None, entries: Optional[List[Entry]] = None, denomination: Optional[Denomination] = None, description: Optional[str] = None) -> None: if not isinstance(transaction, Transaction): raise TypeError('transaction must be of type `Transaction`') self._transaction_id = transaction.id_ if time: if not isinstance(time, datetime): raise TypeError('time must be of type `datetime`') self._time = AmatinoTime(time) else: self._time = AmatinoTime(transaction.time) if entries: self._entries = Transaction._Entries(entries) else: self._entries = Transaction._Entries(transaction.entries) if denomination: if not isinstance(denomination, Denomination): raise TypeError( 'demomination must be of type `Denomination`') self._denomination = denomination else: self._denomination = transaction.denomination if description: self._description = Transaction._Description(description) else: self._description = Transaction._Description( transaction.description) return def serialise(self) -> Dict[str, Any]: if isinstance(self._denomination, CustomUnit): custom_unit_id = self._denomination.id_ global_unit_id = None else: custom_unit_id = None global_unit_id = self._denomination.id_ data = { 'transaction_id': self._transaction_id, 'transaction_time': self._time.serialise(), 'custom_unit_denomination': custom_unit_id, 'global_unit_denomination': global_unit_id, 'description': self._description.serialise(), 'entries': self._entries.serialise() } return data class _Description(ConstrainedString): def __init__(self, string: Optional[str] = None) -> None: if string is None: string = '' super().__init__(string, 'description', Transaction.MAX_DESCRIPTION_LENGTH) return class _Entries(Encodable): def __init__(self, entries: List[Entry]) -> None: if not isinstance(entries, list): raise TypeError('entries must be of type List[Entry]') if False in [isinstance(e, Entry) for e in entries]: raise TypeError('entries must be of type List[Entry]') debits = sum([e.amount for e in entries if e.side == Side.debit]) credits_ = sum( [e.amount for e in entries if e.side == Side.credit]) if debits != credits_: raise ValueError('sum of debits must equal sum of credits') self._entries = entries return entries = Immutable(lambda s: s._entries) debits = Immutable( lambda s: [e for e in s._entries if e.side == Side.debit]) credits_ = Immutable( lambda s: [e for e in s._entries if e.side == Side.credit]) def serialise(self) -> List[Dict]: return [e.serialise() for e in self._entries]