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)
Пример #3
0
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)
Пример #6
0
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
Пример #7
0
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
Пример #8
0
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
Пример #17
0
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
Пример #18
0
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]