class Account(db.Model): creditor_id = db.Column(db.BigInteger, primary_key=True) debtor_id = db.Column(db.BigInteger, primary_key=True) created_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False) __table_args__ = ( db.ForeignKeyConstraint(['creditor_id'], ['creditor.creditor_id'], ondelete='CASCADE'), db.CheckConstraint(latest_update_id > 0), ) data = db.relationship('AccountData', uselist=False, cascade='all', passive_deletes=True) knowledge = db.relationship('AccountKnowledge', uselist=False, cascade='all', passive_deletes=True) exchange = db.relationship('AccountExchange', uselist=False, cascade='all', passive_deletes=True) display = db.relationship('AccountDisplay', uselist=False, cascade='all', passive_deletes=True)
class RunningTransfer(db.Model): _cr_seq = db.Sequence('coordinator_request_id_seq', metadata=db.Model.metadata) creditor_id = db.Column(db.BigInteger, primary_key=True) transfer_uuid = db.Column(pg.UUID(as_uuid=True), primary_key=True) debtor_id = db.Column(db.BigInteger, nullable=False) amount = db.Column(db.BigInteger, nullable=False) recipient_uri = db.Column(db.String, nullable=False) recipient = db.Column(db.String, nullable=False) transfer_note_format = db.Column(db.String, nullable=False) transfer_note = db.Column(db.String, nullable=False) initiated_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) finalized_at = db.Column(db.TIMESTAMP(timezone=True)) error_code = db.Column(db.String) total_locked_amount = db.Column(db.BigInteger) deadline = db.Column(db.TIMESTAMP(timezone=True)) min_interest_rate = db.Column(db.REAL, nullable=False, default=-100.0) locked_amount = db.Column(db.BigInteger, nullable=False, default=0) coordinator_request_id = db.Column(db.BigInteger, nullable=False, server_default=_cr_seq.next_value()) transfer_id = db.Column(db.BigInteger) latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False) __mapper_args__ = {'eager_defaults': True} __table_args__ = ( db.ForeignKeyConstraint(['creditor_id'], ['creditor.creditor_id'], ondelete='CASCADE'), db.CheckConstraint(amount >= 0), db.CheckConstraint(total_locked_amount >= 0), db.CheckConstraint(min_interest_rate >= -100.0), db.CheckConstraint(locked_amount >= 0), db.CheckConstraint(latest_update_id > 0), db.CheckConstraint(or_(error_code == null(), finalized_at != null())), db.Index('idx_coordinator_request_id', creditor_id, coordinator_request_id, unique=True), { 'comment': 'Represents an initiated direct transfer. A new row is inserted when ' 'a creditor initiates a new direct transfer. The row is deleted when the ' 'creditor deletes the initiated transfer.', }) @property def is_finalized(self): return bool(self.finalized_at) @property def is_settled(self): return self.transfer_id is not None
class CommittedTransfer(db.Model): creditor_id = db.Column(db.BigInteger, primary_key=True) debtor_id = db.Column(db.BigInteger, primary_key=True) creation_date = db.Column(db.DATE, primary_key=True) transfer_number = db.Column(db.BigInteger, primary_key=True) # NOTE: `acquired_amount`, `principal`, `committed_at`, and # `previous_transfer_number` columns are not be part of the # primary key, but should be included in the primary key index to # allow index-only scans. Because SQLAlchemy does not support this # yet (2020-01-11), the migration file should be edited so as not # to create a "normal" index, but create a "covering" index # instead. acquired_amount = db.Column(db.BigInteger, nullable=False) principal = db.Column(db.BigInteger, nullable=False) committed_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False) previous_transfer_number = db.Column(db.BigInteger, nullable=False) coordinator_type = db.Column(db.String, nullable=False) sender = db.Column(db.String, nullable=False) recipient = db.Column(db.String, nullable=False) transfer_note_format = db.Column(pg.TEXT, nullable=False) transfer_note = db.Column(pg.TEXT, nullable=False) __table_args__ = ( db.CheckConstraint(transfer_number > 0), db.CheckConstraint(acquired_amount != 0), db.CheckConstraint(previous_transfer_number >= 0), db.CheckConstraint(previous_transfer_number < transfer_number), )
class LedgerEntry(db.Model): creditor_id = db.Column(db.BigInteger, primary_key=True) debtor_id = db.Column(db.BigInteger, primary_key=True) entry_id = db.Column(db.BigInteger, primary_key=True) # NOTE: The rest of the columns are not be part of the primary # key, but should be included in the primary key index to allow # index-only scans. Because SQLAlchemy does not support this yet # (2020-01-11), the migration file should be edited so as not to # create a "normal" index, but create a "covering" index instead. creation_date = db.Column(db.DATE) transfer_number = db.Column(db.BigInteger) aquired_amount = db.Column(db.BigInteger, nullable=False) principal = db.Column(db.BigInteger, nullable=False) added_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) __table_args__ = ( db.CheckConstraint(transfer_number > 0), db.CheckConstraint(entry_id > 0), db.CheckConstraint( or_( and_(creation_date == null(), transfer_number == null()), and_(creation_date != null(), transfer_number != null()), )), )
class AccountExchange(db.Model): creditor_id = db.Column(db.BigInteger, primary_key=True) debtor_id = db.Column(db.BigInteger, primary_key=True) policy = db.Column(db.String) min_principal = db.Column(db.BigInteger, nullable=False, default=MIN_INT64) max_principal = db.Column(db.BigInteger, nullable=False, default=MAX_INT64) peg_exchange_rate = db.Column(db.FLOAT) peg_debtor_id = db.Column(db.BigInteger) latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False) __table_args__ = ( db.ForeignKeyConstraint( ['creditor_id', 'debtor_id'], ['account.creditor_id', 'account.debtor_id'], ondelete='CASCADE', ), db.ForeignKeyConstraint( ['creditor_id', 'peg_debtor_id'], ['account_exchange.creditor_id', 'account_exchange.debtor_id'], ), db.CheckConstraint(latest_update_id > 0), db.CheckConstraint(min_principal <= max_principal), db.CheckConstraint(peg_exchange_rate >= 0.0), db.CheckConstraint( or_( and_(peg_debtor_id == null(), peg_exchange_rate == null()), and_(peg_debtor_id != null(), peg_exchange_rate != null()), )), db.Index('idx_peg_debtor_id', creditor_id, peg_debtor_id, postgresql_where=peg_debtor_id != null()), )
class AccountKnowledge(db.Model): creditor_id = db.Column(db.BigInteger, primary_key=True) debtor_id = db.Column(db.BigInteger, primary_key=True) data = db.Column(pg.JSON, nullable=False, default={}) latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False) __table_args__ = ( db.ForeignKeyConstraint( ['creditor_id', 'debtor_id'], ['account.creditor_id', 'account.debtor_id'], ondelete='CASCADE', ), db.CheckConstraint(latest_update_id > 0), )
class Signal(db.Model): __abstract__ = True # TODO: Define `send_signalbus_messages` class method, and set # `ModelClass.signalbus_burst_count = N` in models. Make sure # RabbitMQ message headers are set properly for the messages. queue_name: Optional[str] = None @property def event_name(self): # pragma: no cover model = type(self) return f'on_{model.__tablename__}' def send_signalbus_message(self): # pragma: no cover model = type(self) if model.queue_name is None: assert not hasattr(model, 'actor_name'), \ 'SignalModel.actor_name is set, but SignalModel.queue_name is not' actor_name = self.event_name routing_key = f'events.{actor_name}' else: actor_name = model.actor_name routing_key = model.queue_name data = model.__marshmallow_schema__.dump(self) message = dramatiq.Message( queue_name=model.queue_name, actor_name=actor_name, args=(), kwargs=data, options={}, ) protocol_broker.publish_message(message, exchange=MAIN_EXCHANGE_NAME, routing_key=routing_key) inserted_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc)
class ConfigureAccountSignal(Signal): queue_name = 'swpt_accounts' actor_name = 'configure_account' class __marshmallow__(Schema): debtor_id = fields.Integer() creditor_id = fields.Integer() ts = fields.DateTime() seqnum = fields.Integer() negligible_amount = fields.Float() config_data = fields.String() config_flags = fields.Integer() creditor_id = db.Column(db.BigInteger, primary_key=True) debtor_id = db.Column(db.BigInteger, primary_key=True) ts = db.Column(db.TIMESTAMP(timezone=True), primary_key=True) seqnum = db.Column(db.Integer, primary_key=True) negligible_amount = db.Column(db.REAL, nullable=False) config_data = db.Column(db.String, nullable=False, default='') config_flags = db.Column(db.Integer, nullable=False) @classproperty def signalbus_burst_count(self): return current_app.config['APP_FLUSH_CONFIGURE_ACCOUNTS_BURST_COUNT']
class AccountDisplay(db.Model): creditor_id = db.Column(db.BigInteger, primary_key=True) debtor_id = db.Column(db.BigInteger, primary_key=True) debtor_name = db.Column(db.String) amount_divisor = db.Column(db.FLOAT, nullable=False, default=1.0) decimal_places = db.Column(db.Integer, nullable=False, default=0) unit = db.Column(db.String) hide = db.Column(db.BOOLEAN, nullable=False, default=False) latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False) __table_args__ = ( db.ForeignKeyConstraint( ['creditor_id', 'debtor_id'], ['account.creditor_id', 'account.debtor_id'], ondelete='CASCADE', ), db.CheckConstraint(amount_divisor > 0.0), db.CheckConstraint(latest_update_id > 0), db.Index('idx_debtor_name', creditor_id, debtor_name, unique=True, postgresql_where=debtor_name != null()), )
class Creditor(db.Model): STATUS_IS_ACTIVATED_FLAG = 1 << 0 STATUS_IS_DEACTIVATED_FLAG = 1 << 1 _ac_seq = db.Sequence('creditor_reservation_id_seq', metadata=db.Model.metadata) creditor_id = db.Column(db.BigInteger, primary_key=True) # NOTE: The `status_flags` column is not be part of the primary # key, but should be included in the primary key index to allow # index-only scans. Because SQLAlchemy does not support this yet # (2020-01-11), the migration file should be edited so as not to # create a "normal" index, but create a "covering" index instead. status_flags = db.Column( db.SmallInteger, nullable=False, default=DEFAULT_CREDITOR_STATUS, comment="Creditor's status bits: " f"{STATUS_IS_ACTIVATED_FLAG} - is activated, " f"{STATUS_IS_DEACTIVATED_FLAG} - is deactivated.", ) created_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) reservation_id = db.Column(db.BigInteger, server_default=_ac_seq.next_value()) last_log_entry_id = db.Column(db.BigInteger, nullable=False, default=0) creditor_latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) creditor_latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) accounts_list_latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) accounts_list_latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) transfers_list_latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) transfers_list_latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) deactivation_date = db.Column( db.DATE, comment='The date on which the creditor was deactivated. When a creditor gets ' 'deactivated, all its belonging objects (account, transfers, etc.) are ' 'removed. To be deactivated, the creditor must be activated first. Once ' 'deactivated, a creditor stays deactivated until it is deleted. A ' '`NULL` value for this column means either that the creditor has not ' 'been deactivated yet, or that the deactivation date is unknown.', ) __mapper_args__ = {'eager_defaults': True} __table_args__ = ( db.CheckConstraint(creditor_id != ROOT_CREDITOR_ID), db.CheckConstraint(last_log_entry_id >= 0), db.CheckConstraint(creditor_latest_update_id > 0), db.CheckConstraint(accounts_list_latest_update_id > 0), db.CheckConstraint(transfers_list_latest_update_id > 0), db.CheckConstraint(or_( status_flags.op('&')(STATUS_IS_DEACTIVATED_FLAG) == 0, status_flags.op('&')(STATUS_IS_ACTIVATED_FLAG) != 0, )), ) pin_info = db.relationship('PinInfo', uselist=False, cascade='all', passive_deletes=True) @property def is_activated(self): return bool(self.status_flags & Creditor.STATUS_IS_ACTIVATED_FLAG) @property def is_deactivated(self): return bool(self.status_flags & Creditor.STATUS_IS_DEACTIVATED_FLAG) def activate(self): self.status_flags |= Creditor.STATUS_IS_ACTIVATED_FLAG self.reservation_id = None def deactivate(self): self.status_flags |= Creditor.STATUS_IS_DEACTIVATED_FLAG self.deactivation_date = datetime.now(tz=timezone.utc).date() def generate_log_entry_id(self): self.last_log_entry_id += 1 return self.last_log_entry_id
class BaseLogEntry(db.Model): __abstract__ = True # Object type hints: OTH_TRANSFER = 1 OTH_TRANSFERS_LIST = 2 OTH_COMMITTED_TRANSFER = 3 OTH_ACCOUNT_LEDGER = 4 added_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False) object_type = db.Column(db.String) object_uri = db.Column(db.String) object_update_id = db.Column(db.BigInteger) is_deleted = db.Column(db.BOOLEAN, comment='NULL has the same meaning as FALSE.') data = db.Column(pg.JSON) # NOTE: The following columns will be non-NULL for specific # `object_type`s only. They contain information allowing the # object's URI and type to be generated. Thus, the `object_uri` # and `object_type` columns can contain NULL for the most # frequently occuring log entries, saving space in the DB index. AUX_FIELDS = { 'object_type_hint', 'debtor_id', 'creation_date', 'transfer_number', 'transfer_uuid', } object_type_hint = db.Column(db.SmallInteger) debtor_id = db.Column(db.BigInteger) creation_date = db.Column(db.DATE) transfer_number = db.Column(db.BigInteger) transfer_uuid = db.Column(pg.UUID(as_uuid=True)) # NOTE: The following columns will be non-NULL for specific # `object_type`s only. They contain information allowing the # object's JSON data to be generated. Thus, the `data `column can # contain NULL for the most frequently occuring log entries, # saving space in the DB index. DATA_FIELDS = { # The key is the name of the column in the table, the value is # the name of the corresponding JSON property in the `data` # dictionary. 'data_principal': 'principal', 'data_next_entry_id': 'nextEntryId', 'data_finalized_at': 'finalizedAt', 'data_error_code': 'errorCode', } data_principal = db.Column(db.BigInteger) data_next_entry_id = db.Column(db.BigInteger) data_finalized_at = db.Column(db.TIMESTAMP(timezone=True)) data_error_code = db.Column(db.String) @property def is_created(self): return not self.is_deleted and self.object_update_id in [1, None] def get_object_type(self, types) -> str: object_type = self.object_type if object_type is not None: return object_type object_type_hint = self.object_type_hint if object_type_hint == self.OTH_TRANSFER: return types.transfer elif object_type_hint == self.OTH_TRANSFERS_LIST: return types.transfers_list elif object_type_hint == self.OTH_COMMITTED_TRANSFER: return types.committed_transfer elif object_type_hint == self.OTH_ACCOUNT_LEDGER: return types.account_ledger logger = logging.getLogger(__name__) logger.error('Log entry without an object type.') return 'object' def get_object_uri(self, paths) -> str: object_uri = self.object_uri if object_uri is not None: return object_uri object_type_hint = self.object_type_hint if object_type_hint == self.OTH_TRANSFER: transfer_uuid = self.transfer_uuid if transfer_uuid is not None: return paths.transfer( creditorId=self.creditor_id, transferUuid=transfer_uuid, ) elif object_type_hint == self.OTH_TRANSFERS_LIST: return paths.transfers_list(creditorId=self.creditor_id) elif object_type_hint == self.OTH_COMMITTED_TRANSFER: debtor_id = self.debtor_id creation_date = self.creation_date transfer_number = self.transfer_number if debtor_id is not None and creation_date is not None and transfer_number is not None: return paths.committed_transfer( creditorId=self.creditor_id, debtorId=debtor_id, creationDate=creation_date, transferNumber=transfer_number, ) elif object_type_hint == self.OTH_ACCOUNT_LEDGER: debtor_id = self.debtor_id if debtor_id is not None: return paths.account_ledger( creditorId=self.creditor_id, debtorId=self.debtor_id, ) logger = logging.getLogger(__name__) logger.error('Log entry without an object URI.') return '' def get_data_dict(self) -> Optional[Dict]: if isinstance(self.data, dict): return self.data items = self.DATA_FIELDS.items() data = {prop: self._jsonify_attribute(attr) for attr, prop in items if getattr(self, attr) is not None} return data or None def _jsonify_attribute(self, attr_name): value = getattr(self, attr_name) if isinstance(value, datetime): return value.isoformat() return value
class PinInfo(db.Model): STATUS_OFF = 0 STATUS_ON = 1 STATUS_BLOCKED = 2 STATUS_NAME_OFF = 'off' STATUS_NAME_ON = 'on' STATUS_NAME_BLOCKED = 'blocked' STATUS_NAMES = [STATUS_NAME_OFF, STATUS_NAME_ON, STATUS_NAME_BLOCKED] creditor_id = db.Column(db.BigInteger, primary_key=True, autoincrement=False) status = db.Column( db.SmallInteger, nullable=False, default=STATUS_OFF, comment="PIN's status: " f"{STATUS_OFF} - off, " f"{STATUS_ON} - on, " f"{STATUS_BLOCKED} - blocked.", ) cfa = db.Column( db.SmallInteger, nullable=False, default=0, comment='The number of consecutive failed attempts. It gets reset to zero when either ' 'of those events occur: 1) a correct PIN is entered; 2) the PIN is changed.', ) afa = db.Column( db.SmallInteger, nullable=False, default=0, comment='The number of accumulated failed attempts. It gets reset to zero when either ' 'of those events occur: 1) some time has passed since the previous reset; 2) the ' 'PIN is blocked.', ) afa_last_reset_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) pin_length = db.Column(db.SmallInteger, nullable=False, default=0) pin_hmac = db.Column(db.LargeBinary) latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) __table_args__ = ( db.ForeignKeyConstraint(['creditor_id'], ['creditor.creditor_id'], ondelete='CASCADE'), db.CheckConstraint(and_(status >= 0, status < 3)), db.CheckConstraint(or_(status != STATUS_ON, pin_hmac != null())), db.CheckConstraint(cfa >= 0), db.CheckConstraint(afa >= 0), db.CheckConstraint(latest_update_id > 0), db.CheckConstraint(pin_length >= 0), db.CheckConstraint(or_(pin_hmac == null(), func.octet_length(pin_hmac) == 32)), { 'comment': "Represents creditor's Personal Identification Number", } ) @staticmethod def calc_hmac(secret: str, value: str) -> bytes: return hmac.digest(secret.encode('utf8'), value.encode('utf8'), 'sha256') @property def is_required(self) -> bool: return self.status != self.STATUS_OFF @property def status_name(self) -> str: return self.STATUS_NAMES[self.status] @status_name.setter def status_name(self, value: str) -> None: self.status = self.STATUS_NAMES.index(value) def _block(self): self.status = self.STATUS_BLOCKED self.pin_length = 0 self.pin_hmac = None self._reset_afa(datetime.now(tz=timezone.utc)) def _get_max_cfa(self) -> int: n = self.pin_length # NOTE: This is more or less an arbitrary linear # dependency. For `n` between 4 and 10, it generate the # sequence [3, 4, 6, 7, 9, 10, 12]. return int(1.5 * n - 3) def _get_max_afa(self) -> int: n = self.pin_length # NOTE: This is more or less an arbitrary exponential # dependency. For `n` between 4 and 10, it generate the # sequence [10, 31 , 100, 316, 1000, 3162, 10000]. return int(exp(1.1513 * (n - 2))) def _reset_afa(self, current_ts: datetime) -> None: self.afa = 0 self.afa_last_reset_ts = current_ts def _reset_afa_if_necessary(self, reset_interval: timedelta) -> None: current_ts = datetime.now(tz=timezone.utc) if self.afa_last_reset_ts + reset_interval <= current_ts: self._reset_afa(current_ts) def _increment_cfa(self) -> None: self.cfa += 1 if self.cfa >= self._get_max_cfa(): self._block() def _increment_afa(self, reset_interval: timedelta) -> None: self._reset_afa_if_necessary(reset_interval) self.afa += 1 if self.afa >= self._get_max_afa(): self._block() def _register_failed_attempt(self, afa_reset_interval: timedelta) -> None: self._increment_cfa() self._increment_afa(afa_reset_interval) def _register_successful_attempt(self) -> None: self.cfa = 0 def try_value(self, value: Optional[str], secret: str, afa_reset_interval: timedelta) -> bool: if self.status == self.STATUS_BLOCKED: return False if self.is_required: assert self.pin_hmac is not None if value is None: return False if PinInfo.calc_hmac(secret, value) != self.pin_hmac: self._register_failed_attempt(afa_reset_interval) return False self._register_successful_attempt() return True def set_value(self, value: Optional[str], secret: str) -> None: self.cfa = 0 if value is None: self.pin_hmac = None self.pin_length = 0 else: self.pin_hmac = self.calc_hmac(secret, value) self.pin_length = len(value)
class AccountData(db.Model): STATUS_UNREACHABLE_FLAG = 1 << 0 STATUS_OVERFLOWN_FLAG = 1 << 1 CONFIG_SCHEDULED_FOR_DELETION_FLAG = 1 << 0 creditor_id = db.Column(db.BigInteger, primary_key=True) debtor_id = db.Column(db.BigInteger, primary_key=True) creation_date = db.Column(db.DATE, nullable=False, default=DATE0) last_change_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=TS0) last_change_seqnum = db.Column(db.Integer, nullable=False, default=0) principal = db.Column(db.BigInteger, nullable=False, default=0) interest = db.Column(db.FLOAT, nullable=False, default=0.0) last_transfer_number = db.Column(db.BigInteger, nullable=False, default=0) last_transfer_committed_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=TS0) last_heartbeat_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=get_now_utc) # `AccountConfig` data last_config_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=TS0) last_config_seqnum = db.Column(db.Integer, nullable=False, default=0) negligible_amount = db.Column(db.REAL, nullable=False, default=DEFAULT_NEGLIGIBLE_AMOUNT) config_flags = db.Column(db.Integer, nullable=False, default=DEFAULT_CONFIG_FLAGS) config_data = db.Column(db.String, nullable=False, default='') is_config_effectual = db.Column(db.BOOLEAN, nullable=False, default=False) allow_unsafe_deletion = db.Column(db.BOOLEAN, nullable=False, default=False) has_server_account = db.Column(db.BOOLEAN, nullable=False, default=False) config_error = db.Column(db.String) config_latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) config_latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False) # `AccountInfo` data interest_rate = db.Column(db.REAL, nullable=False, default=0.0) last_interest_rate_change_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=TS0) transfer_note_max_bytes = db.Column(db.Integer, nullable=False, default=0) account_id = db.Column(db.String, nullable=False, default='') debtor_info_iri = db.Column(db.String) debtor_info_content_type = db.Column(db.String) debtor_info_sha256 = db.Column(db.LargeBinary) info_latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) info_latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False) # `AccountLedger` data ledger_principal = db.Column(db.BigInteger, nullable=False, default=0) ledger_last_entry_id = db.Column(db.BigInteger, nullable=False, default=0) ledger_last_transfer_number = db.Column(db.BigInteger, nullable=False, default=0) ledger_pending_transfer_ts = db.Column( db.TIMESTAMP(timezone=True), comment= 'When there is a committed transfer that can not be added to the ledger, ' 'because a preceding transfer has not been received yet, this column will ' 'contain the the `committed_at` field of the pending committed ' 'transfer. This column is used to identify "broken" ledgers that can be ' '"repaired". A NULL means that the account has no pending committed ' 'transfers which the system knows of.', ) ledger_latest_update_id = db.Column(db.BigInteger, nullable=False, default=1) ledger_latest_update_ts = db.Column(db.TIMESTAMP(timezone=True), nullable=False) __table_args__ = ( db.ForeignKeyConstraint( ['creditor_id', 'debtor_id'], ['account.creditor_id', 'account.debtor_id'], ondelete='CASCADE', ), db.CheckConstraint(interest_rate >= -100.0), db.CheckConstraint(transfer_note_max_bytes >= 0), db.CheckConstraint(negligible_amount >= 0.0), db.CheckConstraint(last_transfer_number >= 0), db.CheckConstraint(ledger_last_entry_id >= 0), db.CheckConstraint(ledger_last_transfer_number >= 0), db.CheckConstraint(ledger_latest_update_id > 0), db.CheckConstraint(config_latest_update_id > 0), db.CheckConstraint(info_latest_update_id > 0), db.CheckConstraint( or_(debtor_info_sha256 == null(), func.octet_length(debtor_info_sha256) == 32)), ) @property def is_scheduled_for_deletion(self): return bool(self.config_flags & self.CONFIG_SCHEDULED_FOR_DELETION_FLAG) @is_scheduled_for_deletion.setter def is_scheduled_for_deletion(self, value): if value: self.config_flags |= self.CONFIG_SCHEDULED_FOR_DELETION_FLAG else: self.config_flags &= ~self.CONFIG_SCHEDULED_FOR_DELETION_FLAG @property def is_deletion_safe(self): return not self.has_server_account and self.is_scheduled_for_deletion and self.is_config_effectual @property def ledger_interest(self) -> int: interest = self.interest current_balance = self.principal + interest if current_balance > 0.0: current_ts = datetime.now(tz=timezone.utc) passed_seconds = max(0.0, (current_ts - self.last_change_ts).total_seconds()) try: k = math.log(1.0 + self.interest_rate / 100.0) / SECONDS_IN_YEAR current_balance *= math.exp(k * passed_seconds) except ValueError: assert self.interest_rate < -99.9999 current_balance = 0.0 interest = current_balance - self.principal if math.isnan(interest): interest = 0.0 if math.isfinite(interest): interest = math.floor(interest) if interest > MAX_INT64: interest = MAX_INT64 if interest < MIN_INT64: interest = MIN_INT64 return interest