zakat
xxx

_____ _ _ _ _ _
|__ /__ _| | ____ _| |_ | | (_) |__ _ __ __ _ _ __ _ _ / // _| |/ / _ | __| | | | | '_ \| '__/ _` | '__| | | | / /| (_| | < (_| | |_ | |___| | |_) | | | (_| | | | |_| | /______,_|_|___,_|__| |_____|_|_.__/|_| __,_|_| __, | |___/

"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف ... Never Trust, Always Verify ...

This file provides the ZakatLibrary classes, functions for tracking and calculating Zakat.

 1"""
 2 _____     _         _     _     _ _                          
 3|__  /__ _| | ____ _| |_  | |   (_) |__  _ __ __ _ _ __ _   _ 
 4  / // _` | |/ / _` | __| | |   | | '_ \| '__/ _` | '__| | | |
 5 / /| (_| |   < (_| | |_  | |___| | |_) | | | (_| | |  | |_| |
 6/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_|  \__,_|_|   \__, |
 7                                                        |___/ 
 8
 9"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف
10... Never Trust, Always Verify ...
11
12This file provides the ZakatLibrary classes, functions for tracking and calculating Zakat.
13"""
14# Importing necessary classes and functions from the main module
15from .zakat_tracker import (
16    ZakatTracker,
17    Action,
18    JSONEncoder,
19    MathOperation,
20)
21
22# Version information for the module
23__version__ = ZakatTracker.Version()
24__all__ = [
25    "ZakatTracker",
26    "Action",
27    "JSONEncoder",
28    "MathOperation",
29]
class ZakatTracker:
  91class ZakatTracker:
  92	"""
  93    A class for tracking and calculating Zakat.
  94
  95    This class provides functionalities for recording transactions, calculating Zakat due,
  96    and managing account balances. It also offers features like importing transactions from
  97    CSV files, exporting data to JSON format, and saving/loading the tracker state.
  98
  99    The `ZakatTracker` class is designed to handle both positive and negative transactions,
 100    allowing for flexible tracking of financial activities related to Zakat. It also supports
 101    the concept of a "nisab" (minimum threshold for Zakat) and a "haul" (complete one year for Transaction) can calculate Zakat due
 102    based on the current silver price.
 103
 104    The class uses a pickle file as its database to persist the tracker state,
 105    ensuring data integrity across sessions. It also provides options for enabling or
 106    disabling history tracking, allowing users to choose their preferred level of detail.
 107
 108    In addition, the `ZakatTracker` class includes various helper methods like
 109    `time`, `time_to_datetime`, `lock`, `free`, `recall`, `export_json`,
 110    and more. These methods provide additional functionalities and flexibility
 111    for interacting with and managing the Zakat tracker.
 112
 113    Attributes:
 114        ZakatCut (function): A function to calculate the Zakat percentage.
 115        TimeCycle (function): A function to determine the time cycle for Zakat.
 116        Nisab (function): A function to calculate the Nisab based on the silver price.
 117        Version (str): The version of the ZakatTracker class.
 118
 119	Data Structure:
 120        The ZakatTracker class utilizes a nested dictionary structure called "_vault" to store and manage data.
 121
 122        _vault (dict):
 123            - account (dict):
 124                - {account_number} (dict):
 125                    - balance (int): The current balance of the account.
 126                    - box (dict): A dictionary storing transaction details.
 127                        - {timestamp} (dict):
 128                            - capital (int): The initial amount of the transaction.
 129                            - count (int): The number of times Zakat has been calculated for this transaction.
 130                            - last (int): The timestamp of the last Zakat calculation.
 131                            - rest (int): The remaining amount after Zakat deductions and withdrawal.
 132                            - total (int): The total Zakat deducted from this transaction.
 133                    - count (int): The total number of transactions for the account.
 134                    - log (dict): A dictionary storing transaction logs.
 135                        - {timestamp} (dict):
 136                            - value (int): The transaction amount (positive or negative).
 137                            - desc (str): The description of the transaction.
 138                            - file (dict): A dictionary storing file references associated with the transaction.
 139                    - zakatable (bool): Indicates whether the account is subject to Zakat.
 140            - history (dict):
 141                - {timestamp} (list): A list of dictionaries storing the history of actions performed.
 142                    - {action_dict} (dict):
 143                        - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
 144                        - account (str): The account number associated with the action.
 145                        - ref (int): The reference number of the transaction.
 146                        - file (int): The reference number of the file (if applicable).
 147                        - key (str): The key associated with the action (e.g., 'rest', 'total').
 148                        - value (int): The value associated with the action.
 149                        - math (MathOperation): The mathematical operation performed (if applicable).
 150            - lock (int or None): The timestamp indicating the current lock status (None if not locked).
 151            - report (dict):
 152                - {timestamp} (tuple): A tuple storing Zakat report details.
 153
 154    """
 155
 156	# Hybrid Constants
 157	ZakatCut	= lambda x: 0.025*x # Zakat Cut in one Lunar Year
 158	TimeCycle	= lambda days = 355: int(60*60*24*days*1e9) # Lunar Year in nanoseconds
 159	Nisab		= lambda x: 595*x # Silver Price in Local currency value
 160	Version		= lambda  : '0.2.3'
 161
 162	def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True):
 163		"""
 164        Initialize ZakatTracker with database path and history mode.
 165
 166        Parameters:
 167        db_path (str): The path to the database file. Default is "zakat.pickle".
 168        history_mode (bool): The mode for tracking history. Default is True.
 169
 170        Returns:
 171        None
 172        """
 173		self.reset()
 174		self._history(history_mode)
 175		self.path(db_path)
 176		self.load()
 177
 178	def path(self, _path: str = None) -> str:
 179		"""
 180        Set or get the database path.
 181
 182        Parameters:
 183        _path (str): The path to the database file. If not provided, it returns the current path.
 184
 185        Returns:
 186        str: The current database path.
 187        """
 188		if _path is not None:
 189			self._vault_path = _path
 190		return self._vault_path
 191
 192	def _history(self, status: bool = None) -> bool:
 193		"""
 194        Enable or disable history tracking.
 195
 196        Parameters:
 197        status (bool): The status of history tracking. Default is True.
 198
 199        Returns:
 200        None
 201        """
 202		if status is not None:
 203			self._history_mode = status
 204		return self._history_mode
 205
 206	def reset(self) -> None:
 207		"""
 208        Reset the internal data structure to its initial state.
 209
 210        Parameters:
 211        None
 212
 213        Returns:
 214        None
 215        """
 216		self._vault = {}
 217		self._vault['account'] = {}
 218		self._vault['exchange'] = {}
 219		self._vault['history'] = {}
 220		self._vault['lock'] = None
 221		self._vault['report'] = {}
 222
 223	@staticmethod
 224	def time(now: datetime = None) -> int:
 225		"""
 226        Generates a timestamp based on the provided datetime object or the current datetime.
 227
 228        Parameters:
 229        now (datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
 230
 231        Returns:
 232        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970), before 1970 will return in negative until 1000AD.
 233        """
 234		if now is None:
 235			now = datetime.datetime.now()
 236		ordinal_day = now.toordinal()
 237		ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10**9
 238		return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)
 239
 240	@staticmethod
 241	def time_to_datetime(ordinal_ns: int) -> datetime:
 242		"""
 243        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
 244
 245        Parameters:
 246        ordinal_ns (int): The ordinal number of days since 1000-01-01.
 247
 248        Returns:
 249        datetime: The corresponding datetime object.
 250        """
 251		ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
 252		ns_in_day = ordinal_ns % 86_400_000_000_000
 253		d = datetime.datetime.fromordinal(ordinal_day)
 254		t = datetime.timedelta(seconds=ns_in_day // 10**9)
 255		return datetime.datetime.combine(d, datetime.time()) + t
 256
 257	def _step(self, action: Action = None, account = None, ref: int = None, file: int = None, value: int = None, key: str = None, math_operation: MathOperation = None) -> int:
 258		"""
 259		This method is responsible for recording the actions performed on the ZakatTracker.
 260
 261		Parameters:
 262		- action (Action): The type of action performed.
 263		- account (str): The account number on which the action was performed.
 264		- ref (int): The reference number of the action.
 265		- file (int): The file reference number of the action.
 266		- value (int): The value associated with the action.
 267		- key (str): The key associated with the action.
 268		- math_operation (MathOperation): The mathematical operation performed during the action.
 269
 270		Returns:
 271		- int: The lock time of the recorded action. If no lock was performed, it returns 0.
 272		"""
 273		if not self._history():
 274			return 0
 275		_lock = self._vault['lock']
 276		if self.nolock():
 277			_lock = self._vault['lock'] = self.time()
 278			self._vault['history'][_lock] = []
 279		if action is None:
 280			return _lock
 281		self._vault['history'][_lock].append({
 282			'action': action,
 283			'account': account,
 284			'ref': ref,
 285			'file': file,
 286			'key': key,
 287			'value': value,
 288			'math': math_operation,
 289		})
 290
 291	def nolock(self) -> bool:
 292		"""
 293        Check if the vault lock is currently not set.
 294
 295        :return: True if the vault lock is not set, False otherwise.
 296        """
 297		return self._vault['lock'] is None
 298
 299	def lock(self) -> int:
 300		"""
 301        Acquires a lock on the ZakatTracker instance.
 302
 303        Returns:
 304        int: The lock ID. This ID can be used to release the lock later.
 305        """
 306		return self._step()
 307
 308	def box(self) -> dict:
 309		"""
 310        Returns a copy of the internal vault dictionary.
 311
 312        This method is used to retrieve the current state of the ZakatTracker object.
 313        It provides a snapshot of the internal data structure, allowing for further
 314        processing or analysis.
 315
 316        :return: A copy of the internal vault dictionary.
 317        """
 318		return self._vault.copy()
 319
 320	def steps(self) -> dict:
 321		"""
 322        Returns a copy of the history of steps taken in the ZakatTracker.
 323
 324        The history is a dictionary where each key is a unique identifier for a step,
 325        and the corresponding value is a dictionary containing information about the step.
 326
 327        :return: A copy of the history of steps taken in the ZakatTracker.
 328        """
 329		return self._vault['history'].copy()
 330
 331	def free(self, _lock: int, auto_save: bool = True) -> bool:
 332		"""
 333        Releases the lock on the database.
 334
 335        Parameters:
 336        _lock (int): The lock ID to be released.
 337        auto_save (bool): Whether to automatically save the database after releasing the lock.
 338
 339        Returns:
 340        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
 341        """
 342		if _lock == self._vault['lock']:
 343			self._vault['lock'] = None
 344			if auto_save:
 345				return self.save(self.path())
 346			return True
 347		return False
 348
 349	def account_exists(self, account) -> bool:
 350		"""
 351        Check if the given account exists in the vault.
 352
 353        Parameters:
 354        account (str): The account number to check.
 355
 356        Returns:
 357        bool: True if the account exists, False otherwise.
 358        """
 359		return account in self._vault['account']
 360
 361	def box_size(self, account) -> int:
 362		"""
 363		Calculate the size of the box for a specific account.
 364
 365		Parameters:
 366		account (str): The account number for which the box size needs to be calculated.
 367
 368		Returns:
 369		int: The size of the box for the given account. If the account does not exist, -1 is returned.
 370		"""
 371		if self.account_exists(account):
 372			return len(self._vault['account'][account]['box'])
 373		return -1
 374
 375	def log_size(self, account) -> int:
 376		"""
 377        Get the size of the log for a specific account.
 378
 379        Parameters:
 380        account (str): The account number for which the log size needs to be calculated.
 381
 382        Returns:
 383        int: The size of the log for the given account. If the account does not exist, -1 is returned.
 384        """
 385		if self.account_exists(account):
 386			return len(self._vault['account'][account]['log'])
 387		return -1
 388
 389	def recall(self, dry = True, debug = False) -> bool:
 390		"""
 391		Revert the last operation.
 392
 393		Parameters:
 394		dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
 395		debug (bool): If True, the function will print debug information. Default is False.
 396
 397		Returns:
 398		bool: True if the operation was successful, False otherwise.
 399		"""
 400		if not self.nolock() or len(self._vault['history']) == 0:
 401			return False
 402		if len(self._vault['history']) <= 0:
 403			return
 404		ref = sorted(self._vault['history'].keys())[-1]
 405		if debug:
 406			print('recall', ref)
 407		memory = self._vault['history'][ref]
 408		if debug:
 409			print(type(memory), 'memory', memory)
 410
 411		limit = len(memory) + 1
 412		sub_positive_log_negative = 0
 413		for i in range(-1, -limit, -1):
 414			x = memory[i]
 415			if debug:
 416				print(type(x), x)
 417			match x['action']:
 418				case Action.CREATE:
 419					if x['account'] is not None:
 420						if self.account_exists(x['account']):
 421							if debug:
 422								print('account', self._vault['account'][x['account']])
 423							assert len(self._vault['account'][x['account']]['box']) == 0
 424							assert self._vault['account'][x['account']]['balance'] == 0
 425							assert self._vault['account'][x['account']]['count'] == 0
 426							if dry:
 427								continue
 428							del self._vault['account'][x['account']]
 429
 430				case Action.TRACK:
 431					if x['account'] is not None:
 432						if self.account_exists(x['account']):
 433							if dry:
 434								continue
 435							self._vault['account'][x['account']]['balance'] -= x['value']
 436							self._vault['account'][x['account']]['count'] -= 1
 437							del self._vault['account'][x['account']]['box'][x['ref']]
 438
 439				case Action.LOG:
 440					if x['account'] is not None:
 441						if self.account_exists(x['account']):
 442							if x['ref'] in self._vault['account'][x['account']]['log']:
 443								if dry:
 444									continue
 445								if sub_positive_log_negative == -x['value']:
 446									self._vault['account'][x['account']]['count'] -= 1
 447									sub_positive_log_negative = 0
 448								del self._vault['account'][x['account']]['log'][x['ref']]
 449
 450				case Action.SUB:
 451					if x['account'] is not None:
 452						if self.account_exists(x['account']):
 453							if x['ref'] in self._vault['account'][x['account']]['box']:
 454								if dry:
 455									continue
 456								self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
 457								self._vault['account'][x['account']]['balance'] += x['value']
 458								sub_positive_log_negative = x['value']
 459
 460				case Action.ADD_FILE:
 461					if x['account'] is not None:
 462						if self.account_exists(x['account']):
 463							if x['ref'] in self._vault['account'][x['account']]['log']:
 464								if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
 465									if dry:
 466										continue
 467									del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
 468
 469				case Action.REMOVE_FILE:
 470					if x['account'] is not None:
 471						if self.account_exists(x['account']):
 472							if x['ref'] in self._vault['account'][x['account']]['log']:
 473								if dry:
 474									continue
 475								self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
 476
 477				case Action.BOX_TRANSFER:
 478					if x['account'] is not None:
 479						if self.account_exists(x['account']):
 480							if x['ref'] in self._vault['account'][x['account']]['box']:
 481								if dry:
 482									continue
 483								self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
 484
 485				case Action.EXCHANGE:
 486					if x['account'] is not None:
 487						if x['account'] in self._vault['exchange']:
 488							if x['ref'] in self._vault['exchange'][x['account']]:
 489								if dry:
 490									continue
 491								del self._vault['exchange'][x['account']][x['ref']]
 492
 493				case Action.REPORT:
 494					if x['ref'] in self._vault['report']:
 495						if dry:
 496							continue
 497						del self._vault['report'][x['ref']]
 498
 499				case Action.ZAKAT:
 500					if x['account'] is not None:
 501						if self.account_exists(x['account']):
 502							if x['ref'] in self._vault['account'][x['account']]['box']:
 503								if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
 504									if dry:
 505										continue
 506									match x['math']:
 507										case MathOperation.ADDITION:
 508											self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x['value']
 509										case MathOperation.EQUAL:
 510											self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
 511										case MathOperation.SUBTRACTION:
 512											self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x['value']
 513
 514		if not dry:
 515			del self._vault['history'][ref]
 516		return True
 517
 518	def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
 519		"""
 520		Check if a specific reference (transaction) exists in the vault for a given account and reference type.
 521
 522		Parameters:
 523		account (str): The account number for which to check the existence of the reference.
 524		ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
 525		ref (int): The reference (transaction) number to check for existence.
 526
 527		Returns:
 528		bool: True if the reference exists for the given account and reference type, False otherwise.
 529		"""
 530		if account in self._vault['account']:
 531			return ref in self._vault['account'][account][ref_type]
 532		return False
 533
 534	def box_exists(self, account: str, ref: int) -> bool:
 535		"""
 536        Check if a specific box (transaction) exists in the vault for a given account and reference.
 537
 538        Parameters:
 539        - account (str): The account number for which to check the existence of the box.
 540        - ref (int): The reference (transaction) number to check for existence.
 541
 542        Returns:
 543        - bool: True if the box exists for the given account and reference, False otherwise.
 544        """
 545		return self.ref_exists(account, 'box', ref)
 546
 547	def track(self, value: int = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
 548		"""
 549        This function tracks a transaction for a specific account.
 550
 551        Parameters:
 552        value (int): The value of the transaction. Default is 0.
 553        desc (str): The description of the transaction. Default is an empty string.
 554        account (str): The account for which the transaction is being tracked. Default is '1'.
 555        logging (bool): Whether to log the transaction. Default is True.
 556        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
 557        debug (bool): Whether to print debug information. Default is False.
 558
 559        Returns:
 560        int: The timestamp of the transaction.
 561
 562        This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
 563
 564		Raises:
 565		ValueError: If the box transction happend again in the same nanosecond time.
 566        """
 567		if created is None:
 568			created = self.time()
 569		_nolock = self.nolock(); self.lock()
 570		if not self.account_exists(account):
 571			if debug:
 572				print(f"account {account} created")
 573			self._vault['account'][account] = {
 574				'balance': 0,
 575				'box': {},
 576				'count': 0,
 577				'log': {},
 578				'zakatable': True,
 579			}
 580			self._step(Action.CREATE, account)
 581		if value == 0:
 582			if _nolock: self.free(self.lock())
 583			return 0
 584		if logging:
 585			self._log(value, desc, account, created, debug)
 586		if debug:
 587			print('create-box', created)
 588		if self.box_exists(account, created):
 589			raise ValueError(f"The box transction happend again in the same nanosecond time({created}).")
 590		if debug:
 591			print('created-box', created)
 592		self._vault['account'][account]['box'][created] = {
 593			'capital': value,
 594			'count': 0,
 595			'last': 0,
 596			'rest': value,
 597			'total': 0,
 598		}
 599		self._step(Action.TRACK, account, ref=created, value=value)
 600		if _nolock: self.free(self.lock())
 601		return created
 602
 603	def log_exists(self, account: str, ref: int) -> bool:
 604		"""
 605        Checks if a specific transaction log entry exists for a given account.
 606
 607        Parameters:
 608        account (str): The account number associated with the transaction log.
 609        ref (int): The reference to the transaction log entry.
 610
 611        Returns:
 612        bool: True if the transaction log entry exists, False otherwise.
 613        """
 614		return self.ref_exists(account, 'log', ref)
 615
 616	def _log(self, value: int, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> int:
 617		"""
 618		Log a transaction into the account's log.
 619
 620		Parameters:
 621		value (int): The value of the transaction.
 622		desc (str): The description of the transaction.
 623		account (str): The account to log the transaction into. Default is '1'.
 624		created (int): The timestamp of the transaction. If not provided, it will be generated.
 625
 626		Returns:
 627		int: The timestamp of the logged transaction.
 628
 629		This method updates the account's balance, count, and log with the transaction details.
 630		It also creates a step in the history of the transaction.
 631
 632		Raises:
 633		ValueError: If the log transction happend again in the same nanosecond time.
 634		"""
 635		if created is None:
 636			created = self.time()
 637		self._vault['account'][account]['balance'] += value
 638		self._vault['account'][account]['count'] += 1
 639		if debug:
 640			print('create-log', created)
 641		if self.log_exists(account, created):
 642			raise ValueError(f"The log transction happend again in the same nanosecond time({created}).")
 643		if debug:
 644			print('created-log', created)
 645		self._vault['account'][account]['log'][created] = {
 646			'value': value,
 647			'desc': desc,
 648			'file': {},
 649		}
 650		self._step(Action.LOG, account, ref=created, value=value)
 651		return created
 652
 653	def exchange(self, account, created: int = None, rate: float = None, description: str = None, debug: bool = False) -> dict:
 654		"""
 655		This method is used to record or retrieve exchange rates for a specific account.
 656
 657		Parameters:
 658		- account (str): The account number for which the exchange rate is being recorded or retrieved.
 659		- created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
 660		- rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
 661		- description (str): A description of the exchange rate.
 662
 663		Returns:
 664		- dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
 665		it returns a dictionary with default values for the rate and description.
 666		"""
 667		if created is None:
 668			created = self.time()
 669		if rate is not None:
 670			if rate <= 1:
 671				return None
 672			if account not in self._vault['exchange']:
 673				self._vault['exchange'][account] = {}
 674			self._vault['exchange'][account][created] = {"rate": rate, "description": description}
 675			self._step(Action.EXCHANGE, account, ref=created, value=rate)
 676			if debug:
 677				print("exchange-created-1", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
 678
 679		if account in self._vault['exchange']:
 680			valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
 681			if valid_rates:
 682				latest_rate = max(valid_rates, key=lambda x: x[0])
 683				if debug:
 684					print("exchange-read-1", f'account: {account}, created: {created}, rate:{rate}, description:{description}', 'latest_rate', latest_rate)
 685				return latest_rate[1] # إرجاع قاموس يحتوي على المعدل والوصف
 686		if debug:
 687			print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
 688		return {"rate": 1, "description": None} # إرجاع القيمة الافتراضية مع وصف فارغ
 689
 690	def exchanges(self) -> dict:
 691		"""
 692        Retrieve the recorded exchange rates for all accounts.
 693
 694        Parameters:
 695        None
 696
 697        Returns:
 698        dict: A dictionary containing all recorded exchange rates.
 699        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
 700        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
 701        """
 702		return self._vault['exchange'].copy()
 703
 704	def accounts(self) -> dict:
 705		"""
 706        Returns a dictionary containing account numbers as keys and their respective balances as values.
 707
 708        Parameters:
 709        None
 710
 711        Returns:
 712        dict: A dictionary where keys are account numbers and values are their respective balances.
 713        """
 714		result = {}
 715		for i in self._vault['account']:
 716			result[i] = self._vault['account'][i]['balance']
 717		return result
 718
 719	def boxes(self, account) -> dict:
 720		"""
 721        Retrieve the boxes (transactions) associated with a specific account.
 722
 723        Parameters:
 724        account (str): The account number for which to retrieve the boxes.
 725
 726        Returns:
 727        dict: A dictionary containing the boxes associated with the given account.
 728        If the account does not exist, an empty dictionary is returned.
 729        """
 730		if self.account_exists(account):
 731			return self._vault['account'][account]['box']
 732		return {}
 733
 734	def logs(self, account) -> dict:
 735		"""
 736		Retrieve the logs (transactions) associated with a specific account.
 737
 738		Parameters:
 739		account (str): The account number for which to retrieve the logs.
 740
 741		Returns:
 742		dict: A dictionary containing the logs associated with the given account.
 743		If the account does not exist, an empty dictionary is returned.
 744		"""
 745		if self.account_exists(account):
 746			return self._vault['account'][account]['log']
 747		return {}
 748
 749	def add_file(self, account: str, ref: int, path: str) -> int:
 750		"""
 751		Adds a file reference to a specific transaction log entry in the vault.
 752
 753		Parameters:
 754		account (str): The account number associated with the transaction log.
 755		ref (int): The reference to the transaction log entry.
 756		path (str): The path of the file to be added.
 757
 758		Returns:
 759		int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
 760		"""
 761		if self.account_exists(account):
 762			if ref in self._vault['account'][account]['log']:
 763				file_ref = self.time()
 764				self._vault['account'][account]['log'][ref]['file'][file_ref] = path
 765				_nolock = self.nolock(); self.lock()
 766				self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
 767				if _nolock: self.free(self.lock())
 768				return file_ref
 769		return 0
 770
 771	def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
 772		"""
 773		Removes a file reference from a specific transaction log entry in the vault.
 774
 775		Parameters:
 776		account (str): The account number associated with the transaction log.
 777		ref (int): The reference to the transaction log entry.
 778		file_ref (int): The reference of the file to be removed.
 779
 780		Returns:
 781		bool: True if the file reference is successfully removed, False otherwise.
 782		"""
 783		if self.account_exists(account):
 784			if ref in self._vault['account'][account]['log']:
 785				if file_ref in self._vault['account'][account]['log'][ref]['file']:
 786					x = self._vault['account'][account]['log'][ref]['file'][file_ref]
 787					del self._vault['account'][account]['log'][ref]['file'][file_ref]
 788					_nolock = self.nolock(); self.lock()
 789					self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
 790					if _nolock: self.free(self.lock())
 791					return True
 792		return False
 793
 794	def balance(self, account: str = 1, cached: bool = True) -> int:
 795		"""
 796		Calculate and return the balance of a specific account.
 797
 798		Parameters:
 799		account (str): The account number. Default is '1'.
 800		cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
 801
 802		Returns:
 803		int: The balance of the account.
 804
 805		Note:
 806		If cached is True, the function returns the cached balance.
 807		If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
 808		"""
 809		if cached:
 810			return self._vault['account'][account]['balance']
 811		x = 0
 812		return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1]
 813
 814	def zakatable(self, account, status: bool = None) -> bool:
 815		"""
 816        Check or set the zakatable status of a specific account.
 817
 818        Parameters:
 819        account (str): The account number.
 820        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
 821
 822        Returns:
 823        bool: The current or updated zakatable status of the account.
 824
 825        Raises:
 826        None
 827
 828        Example:
 829        >>> tracker = ZakatTracker()
 830        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
 831        True
 832        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1'
 833        True
 834        """
 835		if self.account_exists(account):
 836			if status is None:
 837				return self._vault['account'][account]['zakatable']
 838			self._vault['account'][account]['zakatable'] = status
 839			return status
 840		return False
 841
 842	def sub(self, x: int, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
 843		"""
 844		Subtracts a specified value from an account's balance.
 845
 846		Parameters:
 847		x (int): The amount to be subtracted.
 848		desc (str): A description for the transaction. Defaults to an empty string.
 849		account (str): The account from which the value will be subtracted. Defaults to '1'.
 850		created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
 851		debug (bool): A flag indicating whether to print debug information. Defaults to False.
 852
 853		Returns:
 854		tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
 855
 856		If the amount to subtract is greater than the account's balance,
 857		the remaining amount will be transferred to a new transaction with a negative value.
 858
 859		Raises:
 860		ValueError: If the transction happend again in the same nanosecond time.
 861		"""
 862		if x < 0:
 863			return
 864		if x == 0:
 865			return self.track(x, '', account)
 866		if created is None:
 867			created = self.time()
 868		_nolock = self.nolock(); self.lock()
 869		self.track(0, '', account)
 870		self._log(-x, desc, account, created)
 871		ids = sorted(self._vault['account'][account]['box'].keys())
 872		limit = len(ids) + 1
 873		target = x
 874		if debug:
 875			print('ids', ids)
 876		ages = []
 877		for i in range(-1, -limit, -1):
 878			if target == 0:
 879				break
 880			j = ids[i]
 881			if debug:
 882				print('i', i, 'j', j)
 883			if self._vault['account'][account]['box'][j]['rest'] >= target:
 884				self._vault['account'][account]['box'][j]['rest'] -= target
 885				self._step(Action.SUB, account, ref=j, value=target)
 886				ages.append((j, target))
 887				target = 0
 888				break
 889			elif self._vault['account'][account]['box'][j]['rest'] < target and self._vault['account'][account]['box'][j]['rest'] > 0:
 890				chunk = self._vault['account'][account]['box'][j]['rest']
 891				target -= chunk
 892				self._step(Action.SUB, account, ref=j, value=chunk)
 893				ages.append((j, chunk))
 894				self._vault['account'][account]['box'][j]['rest'] = 0
 895		if target > 0:
 896			self.track(-target, desc, account, False, created)
 897			ages.append((created, target))
 898		if _nolock: self.free(self.lock())
 899		return (created, ages)
 900
 901	def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
 902		"""
 903		Transfers a specified value from one account to another.
 904
 905		Parameters:
 906		amount (int): The amount to be transferred.
 907		from_account (str): The account from which the value will be transferred.
 908		to_account (str): The account to which the value will be transferred.
 909		desc (str, optional): A description for the transaction. Defaults to an empty string.
 910		created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
 911		debug (bool): A flag indicating whether to print debug information. Defaults to False.
 912
 913		Returns:
 914		list[int]: A list of timestamps corresponding to the transactions made during the transfer.
 915
 916		Raises:
 917		ValueError: If the transction happend again in the same nanosecond time.
 918		"""
 919		if amount <= 0:
 920			return None
 921		if created is None:
 922			created = self.time()
 923		(_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
 924		times = []
 925		source_exchange = self.exchange(from_account, created)
 926		target_exchange = self.exchange(to_account, created)
 927
 928		if debug:
 929			print('ages', ages)
 930
 931		for age, value in ages:
 932			# Convert source amount to the base currency
 933			source_amount_base = value * source_exchange['rate']
 934			# Convert base amount to the target currency
 935			target_amount = source_amount_base / target_exchange['rate']
 936			# Perform the transfer
 937			if self.box_exists(to_account, age):
 938				if debug:
 939					print('box_exists', age)
 940				capital = self._vault['account'][to_account]['box'][age]['capital']
 941				rest = self._vault['account'][to_account]['box'][age]['rest']
 942				if debug:
 943					print(f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
 944				selected_age = age
 945				if rest + target_amount > capital:
 946					self._vault['account'][to_account]['box'][age]['capital'] += target_amount
 947					selected_age = ZakatTracker.time()
 948				self._vault['account'][to_account]['box'][age]['rest'] += target_amount
 949				self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
 950				y = self._log(target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, debug=debug)
 951				times.append((age, y))
 952				continue
 953			y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
 954			if debug:
 955				print(f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
 956			times.append(y)
 957		return times
 958
 959	def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
 960		"""
 961		Check the eligibility for Zakat based on the given parameters.
 962
 963		Parameters:
 964		silver_gram_price (float): The price of a gram of silver.
 965		nisab (float): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price.
 966		debug (bool): Flag to enable debug mode.
 967		now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
 968		cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
 969
 970		Returns:
 971		tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.
 972		"""
 973		if now is None:
 974			now = self.time()
 975		if cycle is None:
 976			cycle = ZakatTracker.TimeCycle()
 977		if nisab is None:
 978			nisab = ZakatTracker.Nisab(silver_gram_price)
 979		plan = {}
 980		below_nisab = 0
 981		brief = [0, 0, 0]
 982		valid = False
 983		for x in self._vault['account']:
 984			if not self._vault['account'][x]['zakatable']:
 985				continue
 986			_box = self._vault['account'][x]['box']
 987			limit = len(_box) + 1
 988			ids = sorted(self._vault['account'][x]['box'].keys())
 989			for i in range(-1, -limit, -1):
 990				j = ids[i]
 991				if _box[j]['rest'] <= 0:
 992					continue
 993				brief[0] += _box[j]['rest']
 994				index = limit + i - 1
 995				epoch = (now - j) / cycle
 996				if debug:
 997					print(f"Epoch: {epoch}", _box[j])
 998				if _box[j]['last'] > 0:
 999					epoch = (now - _box[j]['last']) / cycle
1000				if debug:
1001					print(f"Epoch: {epoch}")
1002				epoch = floor(epoch)
1003				if debug:
1004					print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1-epoch, epoch)
1005				if epoch == 0:
1006					continue
1007				if debug:
1008					print("Epoch - PASSED")
1009				brief[1] += _box[j]['rest']
1010				if _box[j]['rest'] >= nisab:
1011					total = 0
1012					for _ in range(epoch):
1013						total += ZakatTracker.ZakatCut(_box[j]['rest'] - total)
1014					if total > 0:
1015						if x not in plan:
1016							plan[x] = {}
1017						valid = True
1018						brief[2] += total
1019						plan[x][index] = {'total': total, 'count': epoch}
1020				else:
1021					chunk = ZakatTracker.ZakatCut(_box[j]['rest'])
1022					if chunk > 0:
1023						if x not in plan:
1024							plan[x] = {}
1025						if j not in plan[x].keys():
1026							plan[x][index] = {}
1027						below_nisab += _box[j]['rest']
1028						brief[2] += chunk
1029						plan[x][index]['below_nisab'] = chunk
1030		valid = valid or below_nisab >= nisab
1031		if debug:
1032			print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1033		return (valid, brief, plan)
1034
1035	def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1036		"""
1037		Build payment parts for the zakat distribution.
1038
1039		Parameters:
1040		demand (float): The total demand for payment.
1041		positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1042
1043		Returns:
1044		dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1045		{
1046			'account': {
1047				'account_id': {'balance': float, 'part': float},
1048				...
1049			},
1050			'exceed': bool,
1051			'demand': float,
1052			'total': float,
1053		}
1054		"""
1055		total = 0
1056		parts = {
1057			'account': {},
1058			'exceed': False,
1059			'demand': demand,
1060		}
1061		for x, y in self.accounts().items():
1062			if positive_only and y <= 0:
1063				continue
1064			total += y
1065			parts['account'][x] = {'balance': y, 'part': 0}
1066		parts['total'] = total
1067		return parts
1068
1069	def check_payment_parts(self, parts: dict) -> int:
1070		"""
1071        Checks the validity of payment parts.
1072
1073        Parameters:
1074        parts (dict): A dictionary containing payment parts information.
1075
1076        Returns:
1077        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1078
1079        Error Codes:
1080        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1081        2: 'balance' or 'part' key is missing in parts['account'][x].
1082        3: 'part' value in parts['account'][x] is less than or equal to 0.
1083        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1084        5: 'part' value in parts['account'][x] is less than 0.
1085        6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1086        7: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1087        """
1088		for i in ['demand', 'account', 'total', 'exceed']:
1089			if not i in parts:
1090				return 1
1091		exceed = parts['exceed']
1092		for x in parts['account']:
1093			for j in ['balance', 'part']:
1094				if not j in parts['account'][x]:
1095					return 2
1096				if parts['account'][x]['part'] <= 0:
1097					return 3
1098				if not exceed and parts['account'][x]['balance'] <= 0:
1099					return 4
1100		demand = parts['demand']
1101		z = 0
1102		for _, y in parts['account'].items():
1103			if y['part'] < 0:
1104				return 5
1105			if not exceed and y['part'] > y['balance']:
1106				return 6
1107			z += y['part']
1108		if z != demand:
1109			return 7
1110		return 0
1111
1112	def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool:
1113		"""
1114		Perform Zakat calculation based on the given report and optional parts.
1115
1116		Parameters:
1117		report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1118		parts (dict): A dictionary containing the payment parts for the zakat.
1119		debug (bool): A flag indicating whether to print debug information.
1120
1121		Returns:
1122		bool: True if the zakat calculation is successful, False otherwise.
1123		"""
1124		(valid, _, plan) = report
1125		if not valid:
1126			return valid
1127		parts_exist = parts is not None
1128		if parts_exist:
1129			for part in parts:
1130				if self.check_payment_parts(part) != 0:
1131					return False
1132		if debug:
1133			print('######### zakat #######')
1134			print('parts_exist', parts_exist)
1135		_nolock = self.nolock(); self.lock()
1136		report_time = self.time()
1137		self._vault['report'][report_time] = report
1138		self._step(Action.REPORT, ref=report_time)
1139		created = self.time()
1140		for x in plan:
1141			if debug:
1142				print(plan[x])
1143				print('-------------')
1144				print(self._vault['account'][x]['box'])
1145			ids = sorted(self._vault['account'][x]['box'].keys())
1146			if debug:
1147				print('plan[x]', plan[x])
1148			for i in plan[x].keys():
1149				j = ids[i]
1150				if debug:
1151					print('i', i, 'j', j)
1152				self._step(Action.ZAKAT, x, j, value=self._vault['account'][x]['box'][j]['last'], key='last', math_operation=MathOperation.EQUAL)
1153				self._vault['account'][x]['box'][j]['last'] = created
1154				self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total']
1155				self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='total', math_operation=MathOperation.ADDITION)
1156				self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1157				self._step(Action.ZAKAT, x, j, value=plan[x][i]['count'], key='count', math_operation=MathOperation.ADDITION)
1158				if not parts_exist:
1159					self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total']
1160					self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='rest', math_operation=MathOperation.SUBTRACTION)
1161		if parts_exist:
1162			for transaction in parts:
1163				for account, part in transaction['account'].items():
1164					if debug:
1165						print('zakat-part', account, part['part'])
1166					self.sub(part['part'], 'zakat-part', account, debug=debug)
1167		if _nolock: self.free(self.lock())
1168		return True
1169
1170	def export_json(self, path: str = "data.json") -> bool:
1171		"""
1172        Exports the current state of the ZakatTracker object to a JSON file.
1173
1174        Parameters:
1175        path (str): The path where the JSON file will be saved. Default is "data.json".
1176
1177        Returns:
1178        bool: True if the export is successful, False otherwise.
1179
1180        Raises:
1181        No specific exceptions are raised by this method.
1182        """
1183		with open(path, "w") as file:
1184			json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1185			return True
1186		return False
1187
1188	def save(self, path: str = None) -> bool:
1189		"""
1190		Save the current state of the ZakatTracker object to a pickle file.
1191
1192		Parameters:
1193		path (str): The path where the pickle file will be saved. If not provided, it will use the default path.
1194
1195		Returns:
1196		bool: True if the save operation is successful, False otherwise.
1197		"""
1198		if path is None:
1199			path = self.path()
1200		with open(path, "wb") as f:
1201			pickle.dump(self._vault, f)
1202			return True
1203		return False
1204
1205	def load(self, path: str = None) -> bool:
1206		"""
1207		Load the current state of the ZakatTracker object from a pickle file.
1208
1209		Parameters:
1210		path (str): The path where the pickle file is located. If not provided, it will use the default path.
1211
1212		Returns:
1213		bool: True if the load operation is successful, False otherwise.
1214		"""
1215		if path is None:
1216			path = self.path()
1217		if os.path.exists(path):
1218			with open(path, "rb") as f:
1219				self._vault = pickle.load(f)
1220				return True
1221		return False
1222
1223	def import_csv(self, path: str = 'file.csv') -> tuple:
1224		"""
1225        Import transactions from a CSV file.
1226
1227        Parameters:
1228        path (str): The path to the CSV file. Default is 'file.csv'.
1229
1230        Returns:
1231        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions.
1232
1233        The CSV file should have the following format:
1234        account, desc, value, date
1235        For example:
1236        safe-45, "Some text", 34872, 1988-06-30 00:00:00
1237
1238        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1239        """
1240		cache = []
1241		tmp = "tmp"
1242		try:
1243			with open(tmp, "rb") as f:
1244				cache = pickle.load(f)
1245		except:
1246			pass
1247		date_formats = [
1248			"%Y-%m-%d %H:%M:%S",
1249			"%Y-%m-%dT%H:%M:%S",
1250			"%Y-%m-%dT%H%M%S",
1251			"%Y-%m-%d",
1252		]
1253		created, found, bad = 0, 0, {}
1254		with open(path, newline='', encoding="utf-8") as f:
1255			i = 0
1256			for row in csv.reader(f, delimiter=','):
1257				i += 1
1258				hashed = hash(tuple(row))
1259				if hashed in cache:
1260					found += 1
1261					continue
1262				account = row[0]
1263				desc = row[1]
1264				value = float(row[2])
1265				date = 0
1266				for time_format in date_formats:
1267					try:
1268						date = self.time(datetime.datetime.strptime(row[3], time_format))
1269						break
1270					except:
1271						pass
1272				# TODO: not allowed for negative dates
1273				if date == 0 or value == 0:
1274					bad[i] = row
1275					continue
1276				if value > 0:
1277					self.track(value, desc, account, True, date)
1278				elif value < 0:
1279					self.sub(-value, desc, account, date)
1280				created += 1
1281				cache.append(hashed)
1282		with open(tmp, "wb") as f:
1283				pickle.dump(cache, f)
1284		return (created, found, bad)
1285
1286	########
1287	# TESTS #
1288	#######
1289
1290	@staticmethod
1291	def DurationFromNanoSeconds(ns: int) -> tuple:
1292		"""REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1293			Convert NanoSeconds to Human Readable Time Format.
1294			A NanoSeconds is a unit of time in the International System of Units (SI) equal to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1295			Its symbol is μs, sometimes simplified to us when Unicode is not available.
1296			A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1297
1298			INPUT : ms (AKA: MilliSeconds)
1299			OUTPUT: tuple(string TIMELAPSED, string SPOKENTIME) like format.
1300			OUTPUT Variables: TIMELAPSED, SPOKENTIME
1301
1302			Example  Input: DurationFromNanoSeconds(ns)
1303			**"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1304			Example Output: ('039:0001:047:325:05:02:03:456:789:012', ' 39 Millennia,    1 Century,  47 Years,  325 Days,  5 Hours,  2 Minutes,  3 Seconds,  456 MilliSeconds,  789 MicroSeconds,  12 NanoSeconds')
1305			DurationFromNanoSeconds(1234567890123456789012)
1306		"""
1307		μs, ns      = divmod(ns, 1000)
1308		ms, μs      = divmod(μs, 1000)
1309		s, ms       = divmod(ms, 1000)
1310		m, s        = divmod(s, 60)
1311		h, m        = divmod(m, 60)
1312		d, h        = divmod(h, 24)
1313		y, d        = divmod(d, 365)
1314		c, y        = divmod(y, 100)
1315		n, c        = divmod(c, 10)
1316		TIMELAPSED  = f"{n:03.0f}:{c:04.0f}:{y:03.0f}:{d:03.0f}:{h:02.0f}:{m:02.0f}:{s:02.0f}::{ms:03.0f}::{μs:03.0f}::{ns:03.0f}"
1317		SPOKENTIME  = f"{n: 3d} Millennia, {c: 4d} Century, {y: 3d} Years, {d: 4d} Days, {h: 2d} Hours, {m: 2d} Minutes, {s: 2d} Seconds, {ms: 3d} MilliSeconds, {μs: 3d} MicroSeconds, {ns: 3d} NanoSeconds"
1318		return TIMELAPSED, SPOKENTIME
1319
1320	@staticmethod
1321	def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024
1322		"""
1323		Convert a specific day, month, and year into a timestamp.
1324
1325		Parameters:
1326		day (int): The day of the month.
1327		month (int): The month of the year. Default is 6 (June).
1328		year (int): The year. Default is 2024.
1329
1330		Returns:
1331		int: The timestamp representing the given day, month, and year.
1332
1333		Note:
1334		This method assumes the default month and year if not provided.
1335		"""
1336		return ZakatTracker.time(datetime.datetime(year, month, day))
1337
1338	@staticmethod
1339	def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1340		"""
1341		Generate a random date between two given dates.
1342
1343		Parameters:
1344		start_date (datetime.datetime): The start date from which to generate a random date.
1345		end_date (datetime.datetime): The end date until which to generate a random date.
1346
1347		Returns:
1348		datetime.datetime: A random date between the start_date and end_date.
1349		"""
1350		time_between_dates = end_date - start_date
1351		days_between_dates = time_between_dates.days
1352		random_number_of_days = random.randrange(days_between_dates)
1353		return start_date + datetime.timedelta(days=random_number_of_days)
1354
1355	@staticmethod
1356	def generate_random_csv_file(path: str = "data.csv", count: int = 1000) -> None:
1357		"""
1358		Generate a random CSV file with specified parameters.
1359
1360		Parameters:
1361		path (str): The path where the CSV file will be saved. Default is "data.csv".
1362		count (int): The number of rows to generate in the CSV file. Default is 1000.
1363
1364		Returns:
1365		None. The function generates a CSV file at the specified path with the given count of rows.
1366		Each row contains a randomly generated account, description, value, and date.
1367		The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31.
1368		If the row number is not divisible by 13, the value is multiplied by -1.
1369		"""
1370		with open(path, "w", newline="") as csvfile:
1371			writer = csv.writer(csvfile)
1372			for i in range(count):
1373				account = f"acc-{random.randint(1, 1000)}"
1374				desc = f"Some text {random.randint(1, 1000)}"
1375				value = random.randint(1000, 100000)
1376				date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1377				if not i % 13 == 0:
1378					value *= -1
1379				writer.writerow([account, desc, value, date])
1380
1381	@staticmethod
1382	def create_random_list(max_sum, min_value=0, max_value=10):
1383		"""
1384		Creates a list of random integers whose sum does not exceed the specified maximum.
1385
1386		Args:
1387			max_sum: The maximum allowed sum of the list elements.
1388			min_value: The minimum possible value for an element (inclusive).
1389			max_value: The maximum possible value for an element (inclusive).
1390
1391		Returns:
1392			A list of random integers.
1393		"""
1394		result = []
1395		current_sum = 0
1396
1397		while current_sum < max_sum:
1398			# Calculate the remaining space for the next element
1399			remaining_sum = max_sum - current_sum
1400			# Determine the maximum possible value for the next element
1401			next_max_value = min(remaining_sum, max_value)
1402			# Generate a random element within the allowed range
1403			next_element = random.randint(min_value, next_max_value)
1404			result.append(next_element)
1405			current_sum += next_element
1406
1407		return result
1408
1409	def _test_core(self, restore = False, debug = False):
1410		
1411		random.seed(1234567890)
1412		
1413		# sanity check - random forward time
1414		
1415		xlist = []
1416		limit = 1000
1417		for _ in range(limit):
1418			y = ZakatTracker.time()
1419			z = '-'
1420			if not y in xlist:
1421				xlist.append(y)
1422			else:
1423				z = 'x'
1424			if debug:
1425				print(z, y)
1426		xx = len(xlist)
1427		if debug:
1428			print('count', xx, ' - unique: ', (xx/limit)*100, '%')
1429		assert limit == xx
1430		
1431		# sanity check - convert date since 1000AD
1432		
1433		for year in range(1000, 9000):
1434			ns = ZakatTracker.time(datetime.datetime.strptime(f"{year}-12-30 18:30:45","%Y-%m-%d %H:%M:%S"))
1435			date = ZakatTracker.time_to_datetime(ns)
1436			if debug:
1437				print(date)
1438			assert date.year == year
1439			assert date.month == 12
1440			assert date.day == 30
1441			assert date.hour == 18
1442			assert date.minute == 30
1443			assert date.second in [44, 45]
1444		assert self.nolock()
1445
1446		assert self._history() is True
1447		
1448		table = {
1449			1: [
1450				(0,  10,   10,   10,   10, 1, 1),
1451				(0,  20,   30,   30,   30, 2, 2),
1452				(0,  30,   60,   60,   60, 3, 3),
1453				(1,  15,   45,   45,   45, 3, 4),
1454				(1,  50,   -5,   -5,   -5, 4, 5),
1455				(1, 100, -105, -105, -105, 5, 6),
1456			],
1457			'wallet': [
1458				(1,   90,  -90,  -90,  -90, 1, 1),
1459				(0,  100,   10,   10,   10, 2, 2),
1460				(1,  190, -180, -180, -180, 3, 3),
1461				(0, 1000,  820,  820,  820, 4, 4),
1462			],
1463		}
1464		for x in table:
1465			for y in table[x]:
1466				self.lock()
1467				ref = 0
1468				if y[0] == 0:
1469					ref = self.track(y[1], 'test-add', x, True, ZakatTracker.time(), debug)
1470				else:
1471					(ref, z) = self.sub(y[1], 'test-sub', x, ZakatTracker.time())
1472					if debug:
1473						print('_sub', z, ZakatTracker.time())
1474				assert ref != 0
1475				assert len(self._vault['account'][x]['log'][ref]['file']) == 0
1476				for i in range(3):
1477					file_ref = self.add_file(x, ref, 'file_' + str(i))
1478					sleep(0.0000001)
1479					assert file_ref != 0
1480					if debug:
1481						print('ref', ref, 'file', file_ref)
1482					assert len(self._vault['account'][x]['log'][ref]['file']) == i+1
1483				file_ref = self.add_file(x, ref, 'file_' + str(3))
1484				assert self.remove_file(x, ref, file_ref)
1485				assert self.balance(x) == y[2]
1486				z = self.balance(x, False)
1487				if debug:
1488					print("debug-1", z, y[3])
1489				assert z == y[3]
1490				l = self._vault['account'][x]['log']
1491				z = 0
1492				for i in l:
1493					z += l[i]['value']
1494				if debug:
1495					print("debug-2", z, type(z))
1496					print("debug-2", y[4], type(y[4]))
1497				assert z == y[4]
1498				if debug:
1499					print('debug-2 - PASSED')
1500				assert self.box_size(x) == y[5]
1501				assert self.log_size(x) == y[6]
1502				assert not self.nolock()
1503				self.free(self.lock())
1504				assert self.nolock()
1505			assert self.boxes(x) != {}
1506			assert self.logs(x) != {}
1507
1508			assert self.zakatable(x)
1509			assert self.zakatable(x, False) is False
1510			assert self.zakatable(x) is False
1511			assert self.zakatable(x, True)
1512			assert self.zakatable(x)
1513
1514		if restore is True:
1515			count = len(self._vault['history'])
1516			if debug:
1517				print('history-count', count)
1518			assert count == 10
1519			# try mode
1520			for _ in range(count):
1521				assert self.recall(True, debug)
1522			count = len(self._vault['history'])
1523			if debug:
1524				print('history-count', count)
1525			assert count == 10
1526			_accounts = list(table.keys())
1527			accounts_limit = len(_accounts) + 1
1528			for i in range(-1, -accounts_limit, -1):
1529				account = _accounts[i]
1530				if debug:
1531					print(account, len(table[account]))
1532				transaction_limit = len(table[account]) + 1
1533				for j in range(-1, -transaction_limit, -1):
1534					row = table[account][j]
1535					if debug:
1536						print(row, self.balance(account), self.balance(account, False))
1537					assert self.balance(account) == self.balance(account, False)
1538					assert self.balance(account) == row[2]
1539					assert self.recall(False, debug)
1540			assert self.recall(False, debug) is False
1541			count = len(self._vault['history'])
1542			if debug:
1543				print('history-count', count)
1544			assert count == 0
1545			self.reset()
1546
1547	def test(self, debug: bool = False):
1548
1549		try:
1550
1551			assert self._history()
1552
1553			# Not allowed for duplicate transactions in the same account and time
1554
1555			created = ZakatTracker.time()
1556			self.track(100, 'test-1', 'same', True, created)
1557			failed = False
1558			try:
1559				self.track(50, 'test-1', 'same', True, created)
1560			except:
1561				failed = True
1562			assert failed is True
1563
1564			self.reset()
1565
1566			# Always preserve box age during transfer
1567
1568			created = ZakatTracker.time()
1569			series = [
1570				(30, 4),
1571				(60, 3),
1572				(90, 2),
1573			]
1574			case = {
1575				30: {
1576					'series': series,
1577					'rest' : 150,
1578				},
1579				60: {
1580					'series': series,
1581					'rest' : 120,
1582				},
1583				90: {
1584					'series': series,
1585					'rest' : 90,
1586				},
1587				180: {
1588					'series': series,
1589					'rest' : 0,
1590				},
1591				270: {
1592					'series': series,
1593					'rest' : -90,
1594				},
1595				360: {
1596					'series': series,
1597					'rest' : -180,
1598				},
1599			}
1600
1601			selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
1602				
1603			for total in case:
1604				for x in case[total]['series']:
1605					self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
1606
1607				refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
1608
1609				if debug:
1610					print('refs', refs)
1611
1612				ages_cache_balance = self.balance('ages')
1613				ages_fresh_balance = self.balance('ages', False)
1614				rest = case[total]['rest']
1615				if debug:
1616					print('source', ages_cache_balance, ages_fresh_balance, rest)
1617				assert ages_cache_balance == rest
1618				assert ages_fresh_balance == rest
1619
1620				future_cache_balance = self.balance('future')
1621				future_fresh_balance = self.balance('future', False)
1622				if debug:
1623					print('target', future_cache_balance, future_fresh_balance, total)
1624					print('refs', refs)
1625				assert future_cache_balance == total
1626				assert future_fresh_balance == total
1627
1628				for ref in self._vault['account']['ages']['box']:
1629					ages_capital = self._vault['account']['ages']['box'][ref]['capital']
1630					ages_rest = self._vault['account']['ages']['box'][ref]['rest']
1631					future_capital = 0
1632					if ref in self._vault['account']['future']['box']:
1633						future_capital = self._vault['account']['future']['box'][ref]['capital']
1634					future_rest = 0
1635					if ref in self._vault['account']['future']['box']:
1636						future_rest = self._vault['account']['future']['box'][ref]['rest']
1637					if ages_capital != 0 and future_capital != 0 and future_rest != 0:
1638						if debug:
1639							print('================================================================')
1640							print('ages', ages_capital, ages_rest)
1641							print('future', future_capital, future_rest)
1642						if ages_rest == 0:
1643							assert ages_capital == future_capital
1644						elif ages_rest < 0:
1645							assert -ages_capital == future_capital
1646						elif ages_rest > 0:
1647							assert ages_capital == ages_rest + future_capital
1648				self.reset()
1649				assert len(self._vault['history']) == 0
1650
1651			assert self._history()
1652			assert self._history(False) is False
1653			assert self._history() is False
1654			assert self._history(True)
1655			assert self._history()
1656
1657			self._test_core(True, debug)
1658			self._test_core(False, debug)
1659
1660			transaction = [
1661				(
1662					20, 'wallet', 1, 800, 800, 800, 4, 5,
1663									-85, -85, -85, 6, 7,
1664				),
1665				(
1666					750, 'wallet', 'safe',  50,   50,  50, 4, 6,
1667											750, 750, 750, 1, 1,
1668				),
1669				(
1670					600, 'safe', 'bank', 150, 150, 150, 1, 2,
1671										600, 600, 600, 1, 1,
1672				),
1673			]
1674			for z in transaction:
1675				self.lock()
1676				x = z[1]
1677				y = z[2]
1678				self.transfer(z[0], x, y, 'test-transfer', debug=debug)
1679				assert self.balance(x) == z[3]
1680				xx = self.accounts()[x]
1681				assert xx == z[3]
1682				assert self.balance(x, False) == z[4]
1683				assert xx == z[4]
1684
1685				l = self._vault['account'][x]['log']
1686				s = 0
1687				for i in l:
1688					s += l[i]['value']
1689				if debug:
1690					print('s', s, 'z[5]', z[5])
1691				assert s == z[5]
1692
1693				assert self.box_size(x) == z[6]
1694				assert self.log_size(x) == z[7]
1695
1696				yy = self.accounts()[y]
1697				assert self.balance(y) == z[8]
1698				assert yy == z[8]
1699				assert self.balance(y, False) == z[9]
1700				assert yy == z[9]
1701
1702				l = self._vault['account'][y]['log']
1703				s = 0
1704				for i in l:
1705					s += l[i]['value']
1706				assert s == z[10]
1707
1708				assert self.box_size(y) == z[11]
1709				assert self.log_size(y) == z[12]
1710
1711			if debug:
1712				pp().pprint(self.check(2.17))
1713
1714			assert not self.nolock()
1715			history_count = len(self._vault['history'])
1716			if debug:
1717				print('history-count', history_count)
1718			assert history_count == 11
1719			assert not self.free(ZakatTracker.time())
1720			assert self.free(self.lock())
1721			assert self.nolock()
1722			assert len(self._vault['history']) == 11
1723
1724			# storage
1725
1726			_path = self.path('test.pickle')
1727			if os.path.exists(_path):
1728				os.remove(_path)
1729			self.save()
1730			assert os.path.getsize(_path) > 0
1731			self.reset()
1732			assert self.recall(False, debug) is False
1733			self.load()
1734			assert self._vault['account'] is not None
1735
1736			# recall
1737
1738			assert self.nolock()
1739			assert len(self._vault['history']) == 11
1740			assert self.recall(False, debug) is True
1741			assert len(self._vault['history']) == 10
1742			assert self.recall(False, debug) is True
1743			assert len(self._vault['history']) == 9
1744
1745			# csv
1746			
1747			_path = "test.csv"
1748			count = 1000
1749			if os.path.exists(_path):
1750				os.remove(_path)
1751			self.generate_random_csv_file(_path, count)
1752			assert os.path.getsize(_path) > 0
1753			tmp = "tmp"
1754			if os.path.exists(tmp):
1755				os.remove(tmp)
1756			(created, found, bad) = self.import_csv(_path)
1757			bad_count = len(bad)
1758			if debug:
1759				print(f"csv-imported: ({created}, {found}, {bad_count})")
1760			tmp_size = os.path.getsize(tmp)
1761			assert tmp_size > 0
1762			assert created + found + bad_count == count
1763			assert created == count
1764			assert bad_count == 0
1765			(created_2, found_2, bad_2) = self.import_csv(_path)
1766			bad_2_count = len(bad_2)
1767			if debug:
1768				print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
1769				print(bad)
1770			assert tmp_size == os.path.getsize(tmp)
1771			assert created_2 + found_2 + bad_2_count == count
1772			assert created == found_2
1773			assert bad_count == bad_2_count
1774			assert found_2 == count
1775			assert bad_2_count == 0
1776			assert created_2 == 0
1777
1778			# payment parts
1779
1780			positive_parts = self.build_payment_parts(100, positive_only=True)
1781			assert self.check_payment_parts(positive_parts) != 0
1782			assert self.check_payment_parts(positive_parts) != 0
1783			all_parts = self.build_payment_parts(300, positive_only= False)
1784			assert self.check_payment_parts(all_parts) != 0
1785			assert self.check_payment_parts(all_parts) != 0
1786			if debug:
1787				pp().pprint(positive_parts)
1788				pp().pprint(all_parts)
1789			# dynamic discount
1790			suite = []
1791			count = 3
1792			for exceed in [False, True]:
1793				case = []
1794				for parts in [positive_parts, all_parts]:
1795					part = parts.copy()
1796					demand = part['demand']
1797					if debug:
1798						print(demand, part['total'])
1799					i = 0
1800					z = demand / count
1801					cp = {
1802						'account': {},
1803						'demand': demand,
1804						'exceed': exceed,
1805						'total': part['total'],
1806					}
1807					for x, y in part['account'].items():
1808						if exceed and z <= demand:
1809							i += 1
1810							y['part'] = z
1811							if debug:
1812								print(exceed, y)
1813							cp['account'][x] = y
1814							case.append(y)
1815						elif not exceed and y['balance'] >= z:
1816								i += 1
1817								y['part'] = z
1818								if debug:
1819									print(exceed, y)
1820								cp['account'][x] = y
1821								case.append(y)
1822						if i >= count:
1823								break
1824					if len(cp['account'][x]) > 0:
1825						suite.append(cp)
1826			if debug:
1827				print('suite', len(suite))
1828			for case in suite:
1829				if debug:
1830					print(case)
1831				result = self.check_payment_parts(case)
1832				if debug:
1833					print('check_payment_parts', result)
1834				assert result == 0
1835				
1836			report = self.check(2.17, None, debug)
1837			(valid, brief, plan) = report
1838			if debug:
1839				print('valid', valid)
1840			assert self.zakat(report, parts=suite, debug=debug)
1841
1842			# exchange
1843
1844			self.exchange("cash", 25, 3.75, "2024-06-25")
1845			self.exchange("cash", 22, 3.73, "2024-06-22")
1846			self.exchange("cash", 15, 3.69, "2024-06-15")
1847			self.exchange("cash", 10, 3.66)
1848
1849			for i in range(1, 30):
1850				rate, description = self.exchange("cash", i).values()
1851				if debug:
1852					print(i, rate, description)
1853				if i < 10:
1854					assert rate == 1
1855					assert description is None
1856				elif i == 10:
1857					assert rate == 3.66
1858					assert description is None
1859				elif i < 15:
1860					assert rate == 3.66
1861					assert description is None
1862				elif i == 15:
1863					assert rate == 3.69
1864					assert description is not None
1865				elif i < 22:
1866					assert rate == 3.69
1867					assert description is not None
1868				elif i == 22:
1869					assert rate == 3.73
1870					assert description is not None
1871				elif i >= 25:
1872					assert rate == 3.75
1873					assert description is not None
1874				rate, description = self.exchange("bank", i).values()
1875				if debug:
1876					print(i, rate, description)
1877				assert rate == 1
1878				assert description is None
1879
1880			assert len(self._vault['exchange']) > 0
1881			assert len(self.exchanges()) > 0
1882			self._vault['exchange'].clear()
1883			assert len(self._vault['exchange']) == 0
1884			assert len(self.exchanges()) == 0
1885
1886			# حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
1887			self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
1888			self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
1889			self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
1890			self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
1891
1892			for i in [x * 0.12 for x in range(-15, 21)]:
1893				if i <= 1:
1894					assert self.exchange("test", ZakatTracker.time(), i, f"range({i})") is None
1895				else:
1896					assert self.exchange("test", ZakatTracker.time(), i, f"range({i})") is not None
1897
1898			# اختبار النتائج باستخدام التواريخ بالنانو ثانية
1899			for i in range(1, 31):
1900				timestamp_ns = ZakatTracker.day_to_time(i)
1901				rate, description = self.exchange("cash", timestamp_ns).values()
1902				print(i, rate, description)
1903				if i < 10:
1904					assert rate == 1
1905					assert description is None
1906				elif i == 10:
1907					assert rate == 3.66
1908					assert description is None
1909				elif i < 15:
1910					assert rate == 3.66
1911					assert description is None
1912				elif i == 15:
1913					assert rate == 3.69
1914					assert description is not None
1915				elif i < 22:
1916					assert rate == 3.69
1917					assert description is not None
1918				elif i == 22:
1919					assert rate == 3.73
1920					assert description is not None
1921				elif i >= 25:
1922					assert rate == 3.75
1923					assert description is not None
1924				rate, description = self.exchange("bank", i).values()
1925				print(i, rate, description)
1926				assert rate == 1
1927				assert description is None
1928
1929			assert self.export_json("1000-transactions-test.json")
1930			assert self.save("1000-transactions-test.pickle")
1931
1932			self.reset()
1933
1934			# test transfer between accounts with different exchange rate
1935
1936			debug = True
1937			a_SAR = "Bank (SAR)"
1938			b_USD = "Bank (USD)"
1939			c_SAR = "Safe (SAR)"
1940			# 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
1941			for case in[
1942				(0, a_SAR, "SAR Gift", 1000, 1000),
1943				(1, a_SAR, 1),
1944				(0, b_USD, "USD Gift", 500, 500),
1945				(1, b_USD, 1),
1946				(2, b_USD, 3.75),
1947				(1, b_USD, 3.75),
1948				(3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
1949				(0, c_SAR, "Salary", 750, 750),
1950				(3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
1951				(3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
1952			]:
1953				match(case[0]):
1954					case 0: # track
1955						_, account, desc, x, balance = case
1956						self.track(value=x, desc=desc, account=account, debug=debug)
1957
1958						cached_value = self.balance(account, cached=True)
1959						fresh_value = self.balance(account, cached=False)
1960						if debug:
1961							print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
1962						assert cached_value == balance
1963						assert fresh_value == balance
1964					case 1: # check-exchange
1965						_, account, expected_rate = case
1966						t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
1967						if debug:
1968							print('t-exchange', t_exchange)
1969						assert t_exchange['rate'] == expected_rate
1970					case 2: # do-exchange
1971						_, account, rate = case
1972						self.exchange(account, rate=rate, debug=debug)
1973						b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
1974						if debug:
1975							print('b-exchange', b_exchange)
1976						assert b_exchange['rate'] == rate
1977					case 3: # transfer
1978						_, x, a, b, desc, a_balance, b_balance = case
1979						self.transfer(x, a, b, desc, debug=debug)
1980
1981						cached_value = self.balance(a, cached=True)
1982						fresh_value = self.balance(a, cached=False)
1983						if debug:
1984							print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
1985						assert cached_value == a_balance
1986						assert fresh_value == a_balance
1987
1988						cached_value = self.balance(b, cached=True)
1989						fresh_value = self.balance(b, cached=False)
1990						if debug:
1991							print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
1992						assert cached_value == b_balance
1993						assert fresh_value == b_balance
1994
1995			# Transfer all in many chunks randomly from B to A
1996			a_SAR_balance = 1371.25
1997			b_USD_balance = 501
1998			b_USD_exchange = self.exchange(b_USD)
1999			amounts = ZakatTracker.create_random_list(b_USD_balance)
2000			if debug:
2001				print('amounts', amounts)
2002			i = 0
2003			for x in amounts:
2004				if debug:
2005					print(f'{i} - transfer-with-exchnage({x})')
2006				self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2007				
2008				b_USD_balance -= x
2009				cached_value = self.balance(b_USD, cached=True)
2010				fresh_value = self.balance(b_USD, cached=False)
2011				if debug:
2012					print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', b_USD_balance)
2013				assert cached_value == b_USD_balance
2014				assert fresh_value == b_USD_balance
2015
2016				a_SAR_balance += x * b_USD_exchange['rate']
2017				cached_value = self.balance(a_SAR, cached=True)
2018				fresh_value = self.balance(a_SAR, cached=False)
2019				if debug:
2020					print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', a_SAR_balance, 'rate', b_USD_exchange['rate'])
2021				assert cached_value == a_SAR_balance
2022				assert fresh_value == a_SAR_balance
2023				i += 1
2024			
2025			# Transfer all in many chunks randomly from C to A
2026			c_SAR_balance = 375
2027			amounts = ZakatTracker.create_random_list(c_SAR_balance)
2028			if debug:
2029				print('amounts', amounts)
2030			i = 0
2031			for x in amounts:
2032				if debug:
2033					print(f'{i} - transfer-with-exchnage({x})')
2034				self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2035				
2036				c_SAR_balance -= x
2037				cached_value = self.balance(c_SAR, cached=True)
2038				fresh_value = self.balance(c_SAR, cached=False)
2039				if debug:
2040					print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', c_SAR_balance)
2041				assert cached_value == c_SAR_balance
2042				assert fresh_value == c_SAR_balance
2043
2044				a_SAR_balance += x
2045				cached_value = self.balance(a_SAR, cached=True)
2046				fresh_value = self.balance(a_SAR, cached=False)
2047				if debug:
2048					print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', a_SAR_balance)
2049				assert cached_value == a_SAR_balance
2050				assert fresh_value == a_SAR_balance
2051				i += 1
2052
2053			assert self.export_json("accounts-transfer-with-exchange-rates.json")
2054			assert self.save("accounts-transfer-with-exchange-rates.pickle")
2055
2056			# check & zakat
2057
2058			cases = [
2059				(1000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [
2060					{'safe': {0: {'below_nisab': 25}}},
2061				], False),
2062				(2000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [
2063					{'safe': {0: {'count': 1, 'total': 50}}},
2064				], True),
2065				(10000, 'cave', ZakatTracker.time()-(ZakatTracker.TimeCycle()*3), [
2066					{'cave': {0: {'count': 3, 'total': 731.40625}}},
2067				], True),
2068			]
2069			for case in cases:
2070				if debug:
2071					print("############# check #############")
2072				self.reset()
2073				self.track(case[0], 'test-check', case[1], True, case[2])
2074
2075				assert self.nolock()
2076				assert len(self._vault['history']) == 1
2077				assert self.lock()
2078				assert not self.nolock()
2079				report = self.check(2.17, None, debug)
2080				(valid, brief, plan) = report
2081				assert valid == case[4]
2082				if debug:
2083					print(brief)
2084				assert case[0] == brief[0]
2085				assert case[0] == brief[1]
2086
2087				if debug:
2088					pp().pprint(plan)
2089
2090				for x in plan:
2091					assert case[1] == x
2092					if 'total' in case[3][0][x][0].keys():
2093						assert case[3][0][x][0]['total'] == brief[2]
2094						assert plan[x][0]['total'] == case[3][0][x][0]['total']
2095						assert plan[x][0]['count'] == case[3][0][x][0]['count']
2096					else:
2097						assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2098				if debug:
2099					pp().pprint(report)
2100				result = self.zakat(report, debug=debug)
2101				if debug:
2102					print('zakat-result', result, case[4])
2103				assert result == case[4]
2104				report = self.check(2.17, None, debug)
2105				(valid, brief, plan) = report
2106				assert valid is False
2107
2108			assert len(self._vault['history']) == 2
2109			assert not self.nolock()
2110			assert self.recall(False, debug) is False
2111			self.free(self.lock())
2112			assert self.nolock()
2113			assert len(self._vault['history']) == 2
2114			assert self.recall(False, debug) is True
2115			assert len(self._vault['history']) == 1
2116
2117			assert self.nolock()
2118			assert len(self._vault['history']) == 1
2119
2120			assert self.recall(False, debug) is True
2121			assert len(self._vault['history']) == 0
2122
2123			assert len(self._vault['account']) == 0
2124			assert len(self._vault['history']) == 0
2125			assert len(self._vault['report']) == 0
2126			assert self.nolock()
2127		except:
2128			# pp().pprint(self._vault)
2129			assert self.export_json("test-snapshot.json")
2130			assert self.save("test-snapshot.pickle")
2131			raise

A class for tracking and calculating Zakat.

This class provides functionalities for recording transactions, calculating Zakat due, and managing account balances. It also offers features like importing transactions from CSV files, exporting data to JSON format, and saving/loading the tracker state.

The ZakatTracker class is designed to handle both positive and negative transactions, allowing for flexible tracking of financial activities related to Zakat. It also supports the concept of a "nisab" (minimum threshold for Zakat) and a "haul" (complete one year for Transaction) can calculate Zakat due based on the current silver price.

The class uses a pickle file as its database to persist the tracker state, ensuring data integrity across sessions. It also provides options for enabling or disabling history tracking, allowing users to choose their preferred level of detail.

In addition, the ZakatTracker class includes various helper methods like time, time_to_datetime, lock, free, recall, export_json, and more. These methods provide additional functionalities and flexibility for interacting with and managing the Zakat tracker.

Attributes: ZakatCut (function): A function to calculate the Zakat percentage. TimeCycle (function): A function to determine the time cycle for Zakat. Nisab (function): A function to calculate the Nisab based on the silver price. Version (str): The version of the ZakatTracker class.

Data Structure:
The ZakatTracker class utilizes a nested dictionary structure called "_vault" to store and manage data.

_vault (dict):
    - account (dict):
        - {account_number} (dict):
            - balance (int): The current balance of the account.
            - box (dict): A dictionary storing transaction details.
                - {timestamp} (dict):
                    - capital (int): The initial amount of the transaction.
                    - count (int): The number of times Zakat has been calculated for this transaction.
                    - last (int): The timestamp of the last Zakat calculation.
                    - rest (int): The remaining amount after Zakat deductions and withdrawal.
                    - total (int): The total Zakat deducted from this transaction.
            - count (int): The total number of transactions for the account.
            - log (dict): A dictionary storing transaction logs.
                - {timestamp} (dict):
                    - value (int): The transaction amount (positive or negative).
                    - desc (str): The description of the transaction.
                    - file (dict): A dictionary storing file references associated with the transaction.
            - zakatable (bool): Indicates whether the account is subject to Zakat.
    - history (dict):
        - {timestamp} (list): A list of dictionaries storing the history of actions performed.
            - {action_dict} (dict):
                - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
                - account (str): The account number associated with the action.
                - ref (int): The reference number of the transaction.
                - file (int): The reference number of the file (if applicable).
                - key (str): The key associated with the action (e.g., 'rest', 'total').
                - value (int): The value associated with the action.
                - math (MathOperation): The mathematical operation performed (if applicable).
    - lock (int or None): The timestamp indicating the current lock status (None if not locked).
    - report (dict):
        - {timestamp} (tuple): A tuple storing Zakat report details.
ZakatTracker(db_path: str = 'zakat.pickle', history_mode: bool = True)
162	def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True):
163		"""
164        Initialize ZakatTracker with database path and history mode.
165
166        Parameters:
167        db_path (str): The path to the database file. Default is "zakat.pickle".
168        history_mode (bool): The mode for tracking history. Default is True.
169
170        Returns:
171        None
172        """
173		self.reset()
174		self._history(history_mode)
175		self.path(db_path)
176		self.load()

Initialize ZakatTracker with database path and history mode.

Parameters: db_path (str): The path to the database file. Default is "zakat.pickle". history_mode (bool): The mode for tracking history. Default is True.

Returns: None

def ZakatCut(x):
157	ZakatCut	= lambda x: 0.025*x # Zakat Cut in one Lunar Year
def TimeCycle(days=355):
158	TimeCycle	= lambda days = 355: int(60*60*24*days*1e9) # Lunar Year in nanoseconds
def Nisab(x):
159	Nisab		= lambda x: 595*x # Silver Price in Local currency value
def Version():
160	Version		= lambda  : '0.2.3'
def path(self, _path: str = None) -> str:
178	def path(self, _path: str = None) -> str:
179		"""
180        Set or get the database path.
181
182        Parameters:
183        _path (str): The path to the database file. If not provided, it returns the current path.
184
185        Returns:
186        str: The current database path.
187        """
188		if _path is not None:
189			self._vault_path = _path
190		return self._vault_path

Set or get the database path.

Parameters: _path (str): The path to the database file. If not provided, it returns the current path.

Returns: str: The current database path.

def reset(self) -> None:
206	def reset(self) -> None:
207		"""
208        Reset the internal data structure to its initial state.
209
210        Parameters:
211        None
212
213        Returns:
214        None
215        """
216		self._vault = {}
217		self._vault['account'] = {}
218		self._vault['exchange'] = {}
219		self._vault['history'] = {}
220		self._vault['lock'] = None
221		self._vault['report'] = {}

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

@staticmethod
def time( now: <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'> = None) -> int:
223	@staticmethod
224	def time(now: datetime = None) -> int:
225		"""
226        Generates a timestamp based on the provided datetime object or the current datetime.
227
228        Parameters:
229        now (datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
230
231        Returns:
232        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970), before 1970 will return in negative until 1000AD.
233        """
234		if now is None:
235			now = datetime.datetime.now()
236		ordinal_day = now.toordinal()
237		ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10**9
238		return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)

Generates a timestamp based on the provided datetime object or the current datetime.

Parameters: now (datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.

Returns: int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970), before 1970 will return in negative until 1000AD.

@staticmethod
def time_to_datetime( ordinal_ns: int) -> <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'>:
240	@staticmethod
241	def time_to_datetime(ordinal_ns: int) -> datetime:
242		"""
243        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
244
245        Parameters:
246        ordinal_ns (int): The ordinal number of days since 1000-01-01.
247
248        Returns:
249        datetime: The corresponding datetime object.
250        """
251		ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
252		ns_in_day = ordinal_ns % 86_400_000_000_000
253		d = datetime.datetime.fromordinal(ordinal_day)
254		t = datetime.timedelta(seconds=ns_in_day // 10**9)
255		return datetime.datetime.combine(d, datetime.time()) + t

Converts an ordinal number (number of days since 1000-01-01) to a datetime object.

Parameters: ordinal_ns (int): The ordinal number of days since 1000-01-01.

Returns: datetime: The corresponding datetime object.

def nolock(self) -> bool:
291	def nolock(self) -> bool:
292		"""
293        Check if the vault lock is currently not set.
294
295        :return: True if the vault lock is not set, False otherwise.
296        """
297		return self._vault['lock'] is None

Check if the vault lock is currently not set.

Returns

True if the vault lock is not set, False otherwise.

def lock(self) -> int:
299	def lock(self) -> int:
300		"""
301        Acquires a lock on the ZakatTracker instance.
302
303        Returns:
304        int: The lock ID. This ID can be used to release the lock later.
305        """
306		return self._step()

Acquires a lock on the ZakatTracker instance.

Returns: int: The lock ID. This ID can be used to release the lock later.

def box(self) -> dict:
308	def box(self) -> dict:
309		"""
310        Returns a copy of the internal vault dictionary.
311
312        This method is used to retrieve the current state of the ZakatTracker object.
313        It provides a snapshot of the internal data structure, allowing for further
314        processing or analysis.
315
316        :return: A copy of the internal vault dictionary.
317        """
318		return self._vault.copy()

Returns a copy of the internal vault dictionary.

This method is used to retrieve the current state of the ZakatTracker object. It provides a snapshot of the internal data structure, allowing for further processing or analysis.

Returns

A copy of the internal vault dictionary.

def steps(self) -> dict:
320	def steps(self) -> dict:
321		"""
322        Returns a copy of the history of steps taken in the ZakatTracker.
323
324        The history is a dictionary where each key is a unique identifier for a step,
325        and the corresponding value is a dictionary containing information about the step.
326
327        :return: A copy of the history of steps taken in the ZakatTracker.
328        """
329		return self._vault['history'].copy()

Returns a copy of the history of steps taken in the ZakatTracker.

The history is a dictionary where each key is a unique identifier for a step, and the corresponding value is a dictionary containing information about the step.

Returns

A copy of the history of steps taken in the ZakatTracker.

def free(self, _lock: int, auto_save: bool = True) -> bool:
331	def free(self, _lock: int, auto_save: bool = True) -> bool:
332		"""
333        Releases the lock on the database.
334
335        Parameters:
336        _lock (int): The lock ID to be released.
337        auto_save (bool): Whether to automatically save the database after releasing the lock.
338
339        Returns:
340        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
341        """
342		if _lock == self._vault['lock']:
343			self._vault['lock'] = None
344			if auto_save:
345				return self.save(self.path())
346			return True
347		return False

Releases the lock on the database.

Parameters: _lock (int): The lock ID to be released. auto_save (bool): Whether to automatically save the database after releasing the lock.

Returns: bool: True if the lock is successfully released and (optionally) saved, False otherwise.

def account_exists(self, account) -> bool:
349	def account_exists(self, account) -> bool:
350		"""
351        Check if the given account exists in the vault.
352
353        Parameters:
354        account (str): The account number to check.
355
356        Returns:
357        bool: True if the account exists, False otherwise.
358        """
359		return account in self._vault['account']

Check if the given account exists in the vault.

Parameters: account (str): The account number to check.

Returns: bool: True if the account exists, False otherwise.

def box_size(self, account) -> int:
361	def box_size(self, account) -> int:
362		"""
363		Calculate the size of the box for a specific account.
364
365		Parameters:
366		account (str): The account number for which the box size needs to be calculated.
367
368		Returns:
369		int: The size of the box for the given account. If the account does not exist, -1 is returned.
370		"""
371		if self.account_exists(account):
372			return len(self._vault['account'][account]['box'])
373		return -1

Calculate the size of the box for a specific account.

Parameters: account (str): The account number for which the box size needs to be calculated.

Returns: int: The size of the box for the given account. If the account does not exist, -1 is returned.

def log_size(self, account) -> int:
375	def log_size(self, account) -> int:
376		"""
377        Get the size of the log for a specific account.
378
379        Parameters:
380        account (str): The account number for which the log size needs to be calculated.
381
382        Returns:
383        int: The size of the log for the given account. If the account does not exist, -1 is returned.
384        """
385		if self.account_exists(account):
386			return len(self._vault['account'][account]['log'])
387		return -1

Get the size of the log for a specific account.

Parameters: account (str): The account number for which the log size needs to be calculated.

Returns: int: The size of the log for the given account. If the account does not exist, -1 is returned.

def recall(self, dry=True, debug=False) -> bool:
389	def recall(self, dry = True, debug = False) -> bool:
390		"""
391		Revert the last operation.
392
393		Parameters:
394		dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
395		debug (bool): If True, the function will print debug information. Default is False.
396
397		Returns:
398		bool: True if the operation was successful, False otherwise.
399		"""
400		if not self.nolock() or len(self._vault['history']) == 0:
401			return False
402		if len(self._vault['history']) <= 0:
403			return
404		ref = sorted(self._vault['history'].keys())[-1]
405		if debug:
406			print('recall', ref)
407		memory = self._vault['history'][ref]
408		if debug:
409			print(type(memory), 'memory', memory)
410
411		limit = len(memory) + 1
412		sub_positive_log_negative = 0
413		for i in range(-1, -limit, -1):
414			x = memory[i]
415			if debug:
416				print(type(x), x)
417			match x['action']:
418				case Action.CREATE:
419					if x['account'] is not None:
420						if self.account_exists(x['account']):
421							if debug:
422								print('account', self._vault['account'][x['account']])
423							assert len(self._vault['account'][x['account']]['box']) == 0
424							assert self._vault['account'][x['account']]['balance'] == 0
425							assert self._vault['account'][x['account']]['count'] == 0
426							if dry:
427								continue
428							del self._vault['account'][x['account']]
429
430				case Action.TRACK:
431					if x['account'] is not None:
432						if self.account_exists(x['account']):
433							if dry:
434								continue
435							self._vault['account'][x['account']]['balance'] -= x['value']
436							self._vault['account'][x['account']]['count'] -= 1
437							del self._vault['account'][x['account']]['box'][x['ref']]
438
439				case Action.LOG:
440					if x['account'] is not None:
441						if self.account_exists(x['account']):
442							if x['ref'] in self._vault['account'][x['account']]['log']:
443								if dry:
444									continue
445								if sub_positive_log_negative == -x['value']:
446									self._vault['account'][x['account']]['count'] -= 1
447									sub_positive_log_negative = 0
448								del self._vault['account'][x['account']]['log'][x['ref']]
449
450				case Action.SUB:
451					if x['account'] is not None:
452						if self.account_exists(x['account']):
453							if x['ref'] in self._vault['account'][x['account']]['box']:
454								if dry:
455									continue
456								self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
457								self._vault['account'][x['account']]['balance'] += x['value']
458								sub_positive_log_negative = x['value']
459
460				case Action.ADD_FILE:
461					if x['account'] is not None:
462						if self.account_exists(x['account']):
463							if x['ref'] in self._vault['account'][x['account']]['log']:
464								if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
465									if dry:
466										continue
467									del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
468
469				case Action.REMOVE_FILE:
470					if x['account'] is not None:
471						if self.account_exists(x['account']):
472							if x['ref'] in self._vault['account'][x['account']]['log']:
473								if dry:
474									continue
475								self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
476
477				case Action.BOX_TRANSFER:
478					if x['account'] is not None:
479						if self.account_exists(x['account']):
480							if x['ref'] in self._vault['account'][x['account']]['box']:
481								if dry:
482									continue
483								self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
484
485				case Action.EXCHANGE:
486					if x['account'] is not None:
487						if x['account'] in self._vault['exchange']:
488							if x['ref'] in self._vault['exchange'][x['account']]:
489								if dry:
490									continue
491								del self._vault['exchange'][x['account']][x['ref']]
492
493				case Action.REPORT:
494					if x['ref'] in self._vault['report']:
495						if dry:
496							continue
497						del self._vault['report'][x['ref']]
498
499				case Action.ZAKAT:
500					if x['account'] is not None:
501						if self.account_exists(x['account']):
502							if x['ref'] in self._vault['account'][x['account']]['box']:
503								if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
504									if dry:
505										continue
506									match x['math']:
507										case MathOperation.ADDITION:
508											self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x['value']
509										case MathOperation.EQUAL:
510											self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
511										case MathOperation.SUBTRACTION:
512											self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x['value']
513
514		if not dry:
515			del self._vault['history'][ref]
516		return True

Revert the last operation.

Parameters: dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. debug (bool): If True, the function will print debug information. Default is False.

Returns: bool: True if the operation was successful, False otherwise.

def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
518	def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
519		"""
520		Check if a specific reference (transaction) exists in the vault for a given account and reference type.
521
522		Parameters:
523		account (str): The account number for which to check the existence of the reference.
524		ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
525		ref (int): The reference (transaction) number to check for existence.
526
527		Returns:
528		bool: True if the reference exists for the given account and reference type, False otherwise.
529		"""
530		if account in self._vault['account']:
531			return ref in self._vault['account'][account][ref_type]
532		return False

Check if a specific reference (transaction) exists in the vault for a given account and reference type.

Parameters: account (str): The account number for which to check the existence of the reference. ref_type (str): The type of reference (e.g., 'box', 'log', etc.). ref (int): The reference (transaction) number to check for existence.

Returns: bool: True if the reference exists for the given account and reference type, False otherwise.

def box_exists(self, account: str, ref: int) -> bool:
534	def box_exists(self, account: str, ref: int) -> bool:
535		"""
536        Check if a specific box (transaction) exists in the vault for a given account and reference.
537
538        Parameters:
539        - account (str): The account number for which to check the existence of the box.
540        - ref (int): The reference (transaction) number to check for existence.
541
542        Returns:
543        - bool: True if the box exists for the given account and reference, False otherwise.
544        """
545		return self.ref_exists(account, 'box', ref)

Check if a specific box (transaction) exists in the vault for a given account and reference.

Parameters:

  • account (str): The account number for which to check the existence of the box.
  • ref (int): The reference (transaction) number to check for existence.

Returns:

  • bool: True if the box exists for the given account and reference, False otherwise.
def track( self, value: int = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
547	def track(self, value: int = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
548		"""
549        This function tracks a transaction for a specific account.
550
551        Parameters:
552        value (int): The value of the transaction. Default is 0.
553        desc (str): The description of the transaction. Default is an empty string.
554        account (str): The account for which the transaction is being tracked. Default is '1'.
555        logging (bool): Whether to log the transaction. Default is True.
556        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
557        debug (bool): Whether to print debug information. Default is False.
558
559        Returns:
560        int: The timestamp of the transaction.
561
562        This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
563
564		Raises:
565		ValueError: If the box transction happend again in the same nanosecond time.
566        """
567		if created is None:
568			created = self.time()
569		_nolock = self.nolock(); self.lock()
570		if not self.account_exists(account):
571			if debug:
572				print(f"account {account} created")
573			self._vault['account'][account] = {
574				'balance': 0,
575				'box': {},
576				'count': 0,
577				'log': {},
578				'zakatable': True,
579			}
580			self._step(Action.CREATE, account)
581		if value == 0:
582			if _nolock: self.free(self.lock())
583			return 0
584		if logging:
585			self._log(value, desc, account, created, debug)
586		if debug:
587			print('create-box', created)
588		if self.box_exists(account, created):
589			raise ValueError(f"The box transction happend again in the same nanosecond time({created}).")
590		if debug:
591			print('created-box', created)
592		self._vault['account'][account]['box'][created] = {
593			'capital': value,
594			'count': 0,
595			'last': 0,
596			'rest': value,
597			'total': 0,
598		}
599		self._step(Action.TRACK, account, ref=created, value=value)
600		if _nolock: self.free(self.lock())
601		return created

This function tracks a transaction for a specific account.

Parameters: value (int): The value of the transaction. Default is 0. desc (str): The description of the transaction. Default is an empty string. account (str): The account for which the transaction is being tracked. Default is '1'. logging (bool): Whether to log the transaction. Default is True. created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None. debug (bool): Whether to print debug information. Default is False.

Returns: int: The timestamp of the transaction.

This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.

    Raises:
    ValueError: If the box transction happend again in the same nanosecond time.
def log_exists(self, account: str, ref: int) -> bool:
603	def log_exists(self, account: str, ref: int) -> bool:
604		"""
605        Checks if a specific transaction log entry exists for a given account.
606
607        Parameters:
608        account (str): The account number associated with the transaction log.
609        ref (int): The reference to the transaction log entry.
610
611        Returns:
612        bool: True if the transaction log entry exists, False otherwise.
613        """
614		return self.ref_exists(account, 'log', ref)

Checks if a specific transaction log entry exists for a given account.

Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry.

Returns: bool: True if the transaction log entry exists, False otherwise.

def exchange( self, account, created: int = None, rate: float = None, description: str = None, debug: bool = False) -> dict:
653	def exchange(self, account, created: int = None, rate: float = None, description: str = None, debug: bool = False) -> dict:
654		"""
655		This method is used to record or retrieve exchange rates for a specific account.
656
657		Parameters:
658		- account (str): The account number for which the exchange rate is being recorded or retrieved.
659		- created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
660		- rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
661		- description (str): A description of the exchange rate.
662
663		Returns:
664		- dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
665		it returns a dictionary with default values for the rate and description.
666		"""
667		if created is None:
668			created = self.time()
669		if rate is not None:
670			if rate <= 1:
671				return None
672			if account not in self._vault['exchange']:
673				self._vault['exchange'][account] = {}
674			self._vault['exchange'][account][created] = {"rate": rate, "description": description}
675			self._step(Action.EXCHANGE, account, ref=created, value=rate)
676			if debug:
677				print("exchange-created-1", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
678
679		if account in self._vault['exchange']:
680			valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
681			if valid_rates:
682				latest_rate = max(valid_rates, key=lambda x: x[0])
683				if debug:
684					print("exchange-read-1", f'account: {account}, created: {created}, rate:{rate}, description:{description}', 'latest_rate', latest_rate)
685				return latest_rate[1] # إرجاع قاموس يحتوي على المعدل والوصف
686		if debug:
687			print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
688		return {"rate": 1, "description": None} # إرجاع القيمة الافتراضية مع وصف فارغ

This method is used to record or retrieve exchange rates for a specific account.

Parameters:

  • account (str): The account number for which the exchange rate is being recorded or retrieved.
  • created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
  • rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
  • description (str): A description of the exchange rate.

Returns:

  • dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, it returns a dictionary with default values for the rate and description.
def exchanges(self) -> dict:
690	def exchanges(self) -> dict:
691		"""
692        Retrieve the recorded exchange rates for all accounts.
693
694        Parameters:
695        None
696
697        Returns:
698        dict: A dictionary containing all recorded exchange rates.
699        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
700        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
701        """
702		return self._vault['exchange'].copy()

Retrieve the recorded exchange rates for all accounts.

Parameters: None

Returns: dict: A dictionary containing all recorded exchange rates. The keys are account names or numbers, and the values are dictionaries containing the exchange rates. Each exchange rate dictionary has timestamps as keys and exchange rate details as values.

def accounts(self) -> dict:
704	def accounts(self) -> dict:
705		"""
706        Returns a dictionary containing account numbers as keys and their respective balances as values.
707
708        Parameters:
709        None
710
711        Returns:
712        dict: A dictionary where keys are account numbers and values are their respective balances.
713        """
714		result = {}
715		for i in self._vault['account']:
716			result[i] = self._vault['account'][i]['balance']
717		return result

Returns a dictionary containing account numbers as keys and their respective balances as values.

Parameters: None

Returns: dict: A dictionary where keys are account numbers and values are their respective balances.

def boxes(self, account) -> dict:
719	def boxes(self, account) -> dict:
720		"""
721        Retrieve the boxes (transactions) associated with a specific account.
722
723        Parameters:
724        account (str): The account number for which to retrieve the boxes.
725
726        Returns:
727        dict: A dictionary containing the boxes associated with the given account.
728        If the account does not exist, an empty dictionary is returned.
729        """
730		if self.account_exists(account):
731			return self._vault['account'][account]['box']
732		return {}

Retrieve the boxes (transactions) associated with a specific account.

Parameters: account (str): The account number for which to retrieve the boxes.

Returns: dict: A dictionary containing the boxes associated with the given account. If the account does not exist, an empty dictionary is returned.

def logs(self, account) -> dict:
734	def logs(self, account) -> dict:
735		"""
736		Retrieve the logs (transactions) associated with a specific account.
737
738		Parameters:
739		account (str): The account number for which to retrieve the logs.
740
741		Returns:
742		dict: A dictionary containing the logs associated with the given account.
743		If the account does not exist, an empty dictionary is returned.
744		"""
745		if self.account_exists(account):
746			return self._vault['account'][account]['log']
747		return {}

Retrieve the logs (transactions) associated with a specific account.

Parameters: account (str): The account number for which to retrieve the logs.

Returns: dict: A dictionary containing the logs associated with the given account. If the account does not exist, an empty dictionary is returned.

def add_file(self, account: str, ref: int, path: str) -> int:
749	def add_file(self, account: str, ref: int, path: str) -> int:
750		"""
751		Adds a file reference to a specific transaction log entry in the vault.
752
753		Parameters:
754		account (str): The account number associated with the transaction log.
755		ref (int): The reference to the transaction log entry.
756		path (str): The path of the file to be added.
757
758		Returns:
759		int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
760		"""
761		if self.account_exists(account):
762			if ref in self._vault['account'][account]['log']:
763				file_ref = self.time()
764				self._vault['account'][account]['log'][ref]['file'][file_ref] = path
765				_nolock = self.nolock(); self.lock()
766				self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
767				if _nolock: self.free(self.lock())
768				return file_ref
769		return 0

Adds a file reference to a specific transaction log entry in the vault.

Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry. path (str): The path of the file to be added.

Returns: int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.

def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
771	def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
772		"""
773		Removes a file reference from a specific transaction log entry in the vault.
774
775		Parameters:
776		account (str): The account number associated with the transaction log.
777		ref (int): The reference to the transaction log entry.
778		file_ref (int): The reference of the file to be removed.
779
780		Returns:
781		bool: True if the file reference is successfully removed, False otherwise.
782		"""
783		if self.account_exists(account):
784			if ref in self._vault['account'][account]['log']:
785				if file_ref in self._vault['account'][account]['log'][ref]['file']:
786					x = self._vault['account'][account]['log'][ref]['file'][file_ref]
787					del self._vault['account'][account]['log'][ref]['file'][file_ref]
788					_nolock = self.nolock(); self.lock()
789					self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
790					if _nolock: self.free(self.lock())
791					return True
792		return False

Removes a file reference from a specific transaction log entry in the vault.

Parameters: account (str): The account number associated with the transaction log. ref (int): The reference to the transaction log entry. file_ref (int): The reference of the file to be removed.

Returns: bool: True if the file reference is successfully removed, False otherwise.

def balance(self, account: str = 1, cached: bool = True) -> int:
794	def balance(self, account: str = 1, cached: bool = True) -> int:
795		"""
796		Calculate and return the balance of a specific account.
797
798		Parameters:
799		account (str): The account number. Default is '1'.
800		cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
801
802		Returns:
803		int: The balance of the account.
804
805		Note:
806		If cached is True, the function returns the cached balance.
807		If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
808		"""
809		if cached:
810			return self._vault['account'][account]['balance']
811		x = 0
812		return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1]

Calculate and return the balance of a specific account.

Parameters: account (str): The account number. Default is '1'. cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.

Returns: int: The balance of the account.

Note: If cached is True, the function returns the cached balance. If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.

def zakatable(self, account, status: bool = None) -> bool:
814	def zakatable(self, account, status: bool = None) -> bool:
815		"""
816        Check or set the zakatable status of a specific account.
817
818        Parameters:
819        account (str): The account number.
820        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
821
822        Returns:
823        bool: The current or updated zakatable status of the account.
824
825        Raises:
826        None
827
828        Example:
829        >>> tracker = ZakatTracker()
830        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
831        True
832        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1'
833        True
834        """
835		if self.account_exists(account):
836			if status is None:
837				return self._vault['account'][account]['zakatable']
838			self._vault['account'][account]['zakatable'] = status
839			return status
840		return False

Check or set the zakatable status of a specific account.

Parameters: account (str): The account number. status (bool, optional): The new zakatable status. If not provided, the function will return the current status.

Returns: bool: The current or updated zakatable status of the account.

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1')  # Get the zakatable status of 'account1'
True
def sub( self, x: int, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
842	def sub(self, x: int, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
843		"""
844		Subtracts a specified value from an account's balance.
845
846		Parameters:
847		x (int): The amount to be subtracted.
848		desc (str): A description for the transaction. Defaults to an empty string.
849		account (str): The account from which the value will be subtracted. Defaults to '1'.
850		created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
851		debug (bool): A flag indicating whether to print debug information. Defaults to False.
852
853		Returns:
854		tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
855
856		If the amount to subtract is greater than the account's balance,
857		the remaining amount will be transferred to a new transaction with a negative value.
858
859		Raises:
860		ValueError: If the transction happend again in the same nanosecond time.
861		"""
862		if x < 0:
863			return
864		if x == 0:
865			return self.track(x, '', account)
866		if created is None:
867			created = self.time()
868		_nolock = self.nolock(); self.lock()
869		self.track(0, '', account)
870		self._log(-x, desc, account, created)
871		ids = sorted(self._vault['account'][account]['box'].keys())
872		limit = len(ids) + 1
873		target = x
874		if debug:
875			print('ids', ids)
876		ages = []
877		for i in range(-1, -limit, -1):
878			if target == 0:
879				break
880			j = ids[i]
881			if debug:
882				print('i', i, 'j', j)
883			if self._vault['account'][account]['box'][j]['rest'] >= target:
884				self._vault['account'][account]['box'][j]['rest'] -= target
885				self._step(Action.SUB, account, ref=j, value=target)
886				ages.append((j, target))
887				target = 0
888				break
889			elif self._vault['account'][account]['box'][j]['rest'] < target and self._vault['account'][account]['box'][j]['rest'] > 0:
890				chunk = self._vault['account'][account]['box'][j]['rest']
891				target -= chunk
892				self._step(Action.SUB, account, ref=j, value=chunk)
893				ages.append((j, chunk))
894				self._vault['account'][account]['box'][j]['rest'] = 0
895		if target > 0:
896			self.track(-target, desc, account, False, created)
897			ages.append((created, target))
898		if _nolock: self.free(self.lock())
899		return (created, ages)

Subtracts a specified value from an account's balance.

Parameters: x (int): The amount to be subtracted. desc (str): A description for the transaction. Defaults to an empty string. account (str): The account from which the value will be subtracted. Defaults to '1'. created (int): The timestamp of the transaction. If not provided, the current timestamp will be used. debug (bool): A flag indicating whether to print debug information. Defaults to False.

Returns: tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.

If the amount to subtract is greater than the account's balance, the remaining amount will be transferred to a new transaction with a negative value.

Raises: ValueError: If the transction happend again in the same nanosecond time.

def transfer( self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
901	def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
902		"""
903		Transfers a specified value from one account to another.
904
905		Parameters:
906		amount (int): The amount to be transferred.
907		from_account (str): The account from which the value will be transferred.
908		to_account (str): The account to which the value will be transferred.
909		desc (str, optional): A description for the transaction. Defaults to an empty string.
910		created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
911		debug (bool): A flag indicating whether to print debug information. Defaults to False.
912
913		Returns:
914		list[int]: A list of timestamps corresponding to the transactions made during the transfer.
915
916		Raises:
917		ValueError: If the transction happend again in the same nanosecond time.
918		"""
919		if amount <= 0:
920			return None
921		if created is None:
922			created = self.time()
923		(_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
924		times = []
925		source_exchange = self.exchange(from_account, created)
926		target_exchange = self.exchange(to_account, created)
927
928		if debug:
929			print('ages', ages)
930
931		for age, value in ages:
932			# Convert source amount to the base currency
933			source_amount_base = value * source_exchange['rate']
934			# Convert base amount to the target currency
935			target_amount = source_amount_base / target_exchange['rate']
936			# Perform the transfer
937			if self.box_exists(to_account, age):
938				if debug:
939					print('box_exists', age)
940				capital = self._vault['account'][to_account]['box'][age]['capital']
941				rest = self._vault['account'][to_account]['box'][age]['rest']
942				if debug:
943					print(f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
944				selected_age = age
945				if rest + target_amount > capital:
946					self._vault['account'][to_account]['box'][age]['capital'] += target_amount
947					selected_age = ZakatTracker.time()
948				self._vault['account'][to_account]['box'][age]['rest'] += target_amount
949				self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
950				y = self._log(target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account, debug=debug)
951				times.append((age, y))
952				continue
953			y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
954			if debug:
955				print(f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
956			times.append(y)
957		return times

Transfers a specified value from one account to another.

Parameters: amount (int): The amount to be transferred. from_account (str): The account from which the value will be transferred. to_account (str): The account to which the value will be transferred. desc (str, optional): A description for the transaction. Defaults to an empty string. created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. debug (bool): A flag indicating whether to print debug information. Defaults to False.

Returns: list[int]: A list of timestamps corresponding to the transactions made during the transfer.

Raises: ValueError: If the transction happend again in the same nanosecond time.

def check( self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
 959	def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
 960		"""
 961		Check the eligibility for Zakat based on the given parameters.
 962
 963		Parameters:
 964		silver_gram_price (float): The price of a gram of silver.
 965		nisab (float): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price.
 966		debug (bool): Flag to enable debug mode.
 967		now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
 968		cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
 969
 970		Returns:
 971		tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.
 972		"""
 973		if now is None:
 974			now = self.time()
 975		if cycle is None:
 976			cycle = ZakatTracker.TimeCycle()
 977		if nisab is None:
 978			nisab = ZakatTracker.Nisab(silver_gram_price)
 979		plan = {}
 980		below_nisab = 0
 981		brief = [0, 0, 0]
 982		valid = False
 983		for x in self._vault['account']:
 984			if not self._vault['account'][x]['zakatable']:
 985				continue
 986			_box = self._vault['account'][x]['box']
 987			limit = len(_box) + 1
 988			ids = sorted(self._vault['account'][x]['box'].keys())
 989			for i in range(-1, -limit, -1):
 990				j = ids[i]
 991				if _box[j]['rest'] <= 0:
 992					continue
 993				brief[0] += _box[j]['rest']
 994				index = limit + i - 1
 995				epoch = (now - j) / cycle
 996				if debug:
 997					print(f"Epoch: {epoch}", _box[j])
 998				if _box[j]['last'] > 0:
 999					epoch = (now - _box[j]['last']) / cycle
1000				if debug:
1001					print(f"Epoch: {epoch}")
1002				epoch = floor(epoch)
1003				if debug:
1004					print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1-epoch, epoch)
1005				if epoch == 0:
1006					continue
1007				if debug:
1008					print("Epoch - PASSED")
1009				brief[1] += _box[j]['rest']
1010				if _box[j]['rest'] >= nisab:
1011					total = 0
1012					for _ in range(epoch):
1013						total += ZakatTracker.ZakatCut(_box[j]['rest'] - total)
1014					if total > 0:
1015						if x not in plan:
1016							plan[x] = {}
1017						valid = True
1018						brief[2] += total
1019						plan[x][index] = {'total': total, 'count': epoch}
1020				else:
1021					chunk = ZakatTracker.ZakatCut(_box[j]['rest'])
1022					if chunk > 0:
1023						if x not in plan:
1024							plan[x] = {}
1025						if j not in plan[x].keys():
1026							plan[x][index] = {}
1027						below_nisab += _box[j]['rest']
1028						brief[2] += chunk
1029						plan[x][index]['below_nisab'] = chunk
1030		valid = valid or below_nisab >= nisab
1031		if debug:
1032			print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1033		return (valid, brief, plan)

Check the eligibility for Zakat based on the given parameters.

Parameters: silver_gram_price (float): The price of a gram of silver. nisab (float): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price. debug (bool): Flag to enable debug mode. now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().

Returns: tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.

def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1035	def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1036		"""
1037		Build payment parts for the zakat distribution.
1038
1039		Parameters:
1040		demand (float): The total demand for payment.
1041		positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1042
1043		Returns:
1044		dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1045		{
1046			'account': {
1047				'account_id': {'balance': float, 'part': float},
1048				...
1049			},
1050			'exceed': bool,
1051			'demand': float,
1052			'total': float,
1053		}
1054		"""
1055		total = 0
1056		parts = {
1057			'account': {},
1058			'exceed': False,
1059			'demand': demand,
1060		}
1061		for x, y in self.accounts().items():
1062			if positive_only and y <= 0:
1063				continue
1064			total += y
1065			parts['account'][x] = {'balance': y, 'part': 0}
1066		parts['total'] = total
1067		return parts

Build payment parts for the zakat distribution.

Parameters: demand (float): The total demand for payment. positive_only (bool): If True, only consider accounts with positive balance. Default is True.

Returns: dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: { 'account': { 'account_id': {'balance': float, 'part': float}, ... }, 'exceed': bool, 'demand': float, 'total': float, }

def check_payment_parts(self, parts: dict) -> int:
1069	def check_payment_parts(self, parts: dict) -> int:
1070		"""
1071        Checks the validity of payment parts.
1072
1073        Parameters:
1074        parts (dict): A dictionary containing payment parts information.
1075
1076        Returns:
1077        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1078
1079        Error Codes:
1080        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1081        2: 'balance' or 'part' key is missing in parts['account'][x].
1082        3: 'part' value in parts['account'][x] is less than or equal to 0.
1083        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1084        5: 'part' value in parts['account'][x] is less than 0.
1085        6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1086        7: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1087        """
1088		for i in ['demand', 'account', 'total', 'exceed']:
1089			if not i in parts:
1090				return 1
1091		exceed = parts['exceed']
1092		for x in parts['account']:
1093			for j in ['balance', 'part']:
1094				if not j in parts['account'][x]:
1095					return 2
1096				if parts['account'][x]['part'] <= 0:
1097					return 3
1098				if not exceed and parts['account'][x]['balance'] <= 0:
1099					return 4
1100		demand = parts['demand']
1101		z = 0
1102		for _, y in parts['account'].items():
1103			if y['part'] < 0:
1104				return 5
1105			if not exceed and y['part'] > y['balance']:
1106				return 6
1107			z += y['part']
1108		if z != demand:
1109			return 7
1110		return 0

Checks the validity of payment parts.

Parameters: parts (dict): A dictionary containing payment parts information.

Returns: int: Returns 0 if the payment parts are valid, otherwise returns the error code.

Error Codes: 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 2: 'balance' or 'part' key is missing in parts['account'][x]. 3: 'part' value in parts['account'][x] is less than or equal to 0. 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 5: 'part' value in parts['account'][x] is less than 0. 6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 7: The sum of 'part' values in parts['account'] does not match with 'demand' value.

def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool:
1112	def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool:
1113		"""
1114		Perform Zakat calculation based on the given report and optional parts.
1115
1116		Parameters:
1117		report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1118		parts (dict): A dictionary containing the payment parts for the zakat.
1119		debug (bool): A flag indicating whether to print debug information.
1120
1121		Returns:
1122		bool: True if the zakat calculation is successful, False otherwise.
1123		"""
1124		(valid, _, plan) = report
1125		if not valid:
1126			return valid
1127		parts_exist = parts is not None
1128		if parts_exist:
1129			for part in parts:
1130				if self.check_payment_parts(part) != 0:
1131					return False
1132		if debug:
1133			print('######### zakat #######')
1134			print('parts_exist', parts_exist)
1135		_nolock = self.nolock(); self.lock()
1136		report_time = self.time()
1137		self._vault['report'][report_time] = report
1138		self._step(Action.REPORT, ref=report_time)
1139		created = self.time()
1140		for x in plan:
1141			if debug:
1142				print(plan[x])
1143				print('-------------')
1144				print(self._vault['account'][x]['box'])
1145			ids = sorted(self._vault['account'][x]['box'].keys())
1146			if debug:
1147				print('plan[x]', plan[x])
1148			for i in plan[x].keys():
1149				j = ids[i]
1150				if debug:
1151					print('i', i, 'j', j)
1152				self._step(Action.ZAKAT, x, j, value=self._vault['account'][x]['box'][j]['last'], key='last', math_operation=MathOperation.EQUAL)
1153				self._vault['account'][x]['box'][j]['last'] = created
1154				self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total']
1155				self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='total', math_operation=MathOperation.ADDITION)
1156				self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1157				self._step(Action.ZAKAT, x, j, value=plan[x][i]['count'], key='count', math_operation=MathOperation.ADDITION)
1158				if not parts_exist:
1159					self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total']
1160					self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='rest', math_operation=MathOperation.SUBTRACTION)
1161		if parts_exist:
1162			for transaction in parts:
1163				for account, part in transaction['account'].items():
1164					if debug:
1165						print('zakat-part', account, part['part'])
1166					self.sub(part['part'], 'zakat-part', account, debug=debug)
1167		if _nolock: self.free(self.lock())
1168		return True

Perform Zakat calculation based on the given report and optional parts.

Parameters: report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. parts (dict): A dictionary containing the payment parts for the zakat. debug (bool): A flag indicating whether to print debug information.

Returns: bool: True if the zakat calculation is successful, False otherwise.

def export_json(self, path: str = 'data.json') -> bool:
1170	def export_json(self, path: str = "data.json") -> bool:
1171		"""
1172        Exports the current state of the ZakatTracker object to a JSON file.
1173
1174        Parameters:
1175        path (str): The path where the JSON file will be saved. Default is "data.json".
1176
1177        Returns:
1178        bool: True if the export is successful, False otherwise.
1179
1180        Raises:
1181        No specific exceptions are raised by this method.
1182        """
1183		with open(path, "w") as file:
1184			json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1185			return True
1186		return False

Exports the current state of the ZakatTracker object to a JSON file.

Parameters: path (str): The path where the JSON file will be saved. Default is "data.json".

Returns: bool: True if the export is successful, False otherwise.

Raises: No specific exceptions are raised by this method.

def save(self, path: str = None) -> bool:
1188	def save(self, path: str = None) -> bool:
1189		"""
1190		Save the current state of the ZakatTracker object to a pickle file.
1191
1192		Parameters:
1193		path (str): The path where the pickle file will be saved. If not provided, it will use the default path.
1194
1195		Returns:
1196		bool: True if the save operation is successful, False otherwise.
1197		"""
1198		if path is None:
1199			path = self.path()
1200		with open(path, "wb") as f:
1201			pickle.dump(self._vault, f)
1202			return True
1203		return False

Save the current state of the ZakatTracker object to a pickle file.

Parameters: path (str): The path where the pickle file will be saved. If not provided, it will use the default path.

Returns: bool: True if the save operation is successful, False otherwise.

def load(self, path: str = None) -> bool:
1205	def load(self, path: str = None) -> bool:
1206		"""
1207		Load the current state of the ZakatTracker object from a pickle file.
1208
1209		Parameters:
1210		path (str): The path where the pickle file is located. If not provided, it will use the default path.
1211
1212		Returns:
1213		bool: True if the load operation is successful, False otherwise.
1214		"""
1215		if path is None:
1216			path = self.path()
1217		if os.path.exists(path):
1218			with open(path, "rb") as f:
1219				self._vault = pickle.load(f)
1220				return True
1221		return False

Load the current state of the ZakatTracker object from a pickle file.

Parameters: path (str): The path where the pickle file is located. If not provided, it will use the default path.

Returns: bool: True if the load operation is successful, False otherwise.

def import_csv(self, path: str = 'file.csv') -> tuple:
1223	def import_csv(self, path: str = 'file.csv') -> tuple:
1224		"""
1225        Import transactions from a CSV file.
1226
1227        Parameters:
1228        path (str): The path to the CSV file. Default is 'file.csv'.
1229
1230        Returns:
1231        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions.
1232
1233        The CSV file should have the following format:
1234        account, desc, value, date
1235        For example:
1236        safe-45, "Some text", 34872, 1988-06-30 00:00:00
1237
1238        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1239        """
1240		cache = []
1241		tmp = "tmp"
1242		try:
1243			with open(tmp, "rb") as f:
1244				cache = pickle.load(f)
1245		except:
1246			pass
1247		date_formats = [
1248			"%Y-%m-%d %H:%M:%S",
1249			"%Y-%m-%dT%H:%M:%S",
1250			"%Y-%m-%dT%H%M%S",
1251			"%Y-%m-%d",
1252		]
1253		created, found, bad = 0, 0, {}
1254		with open(path, newline='', encoding="utf-8") as f:
1255			i = 0
1256			for row in csv.reader(f, delimiter=','):
1257				i += 1
1258				hashed = hash(tuple(row))
1259				if hashed in cache:
1260					found += 1
1261					continue
1262				account = row[0]
1263				desc = row[1]
1264				value = float(row[2])
1265				date = 0
1266				for time_format in date_formats:
1267					try:
1268						date = self.time(datetime.datetime.strptime(row[3], time_format))
1269						break
1270					except:
1271						pass
1272				# TODO: not allowed for negative dates
1273				if date == 0 or value == 0:
1274					bad[i] = row
1275					continue
1276				if value > 0:
1277					self.track(value, desc, account, True, date)
1278				elif value < 0:
1279					self.sub(-value, desc, account, date)
1280				created += 1
1281				cache.append(hashed)
1282		with open(tmp, "wb") as f:
1283				pickle.dump(cache, f)
1284		return (created, found, bad)

Import transactions from a CSV file.

Parameters: path (str): The path to the CSV file. Default is 'file.csv'.

Returns: tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions.

The CSV file should have the following format: account, desc, value, date For example: safe-45, "Some text", 34872, 1988-06-30 00:00:00

The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.

@staticmethod
def DurationFromNanoSeconds(ns: int) -> tuple:
1290	@staticmethod
1291	def DurationFromNanoSeconds(ns: int) -> tuple:
1292		"""REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1293			Convert NanoSeconds to Human Readable Time Format.
1294			A NanoSeconds is a unit of time in the International System of Units (SI) equal to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1295			Its symbol is μs, sometimes simplified to us when Unicode is not available.
1296			A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1297
1298			INPUT : ms (AKA: MilliSeconds)
1299			OUTPUT: tuple(string TIMELAPSED, string SPOKENTIME) like format.
1300			OUTPUT Variables: TIMELAPSED, SPOKENTIME
1301
1302			Example  Input: DurationFromNanoSeconds(ns)
1303			**"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1304			Example Output: ('039:0001:047:325:05:02:03:456:789:012', ' 39 Millennia,    1 Century,  47 Years,  325 Days,  5 Hours,  2 Minutes,  3 Seconds,  456 MilliSeconds,  789 MicroSeconds,  12 NanoSeconds')
1305			DurationFromNanoSeconds(1234567890123456789012)
1306		"""
1307		μs, ns      = divmod(ns, 1000)
1308		ms, μs      = divmod(μs, 1000)
1309		s, ms       = divmod(ms, 1000)
1310		m, s        = divmod(s, 60)
1311		h, m        = divmod(m, 60)
1312		d, h        = divmod(h, 24)
1313		y, d        = divmod(d, 365)
1314		c, y        = divmod(y, 100)
1315		n, c        = divmod(c, 10)
1316		TIMELAPSED  = f"{n:03.0f}:{c:04.0f}:{y:03.0f}:{d:03.0f}:{h:02.0f}:{m:02.0f}:{s:02.0f}::{ms:03.0f}::{μs:03.0f}::{ns:03.0f}"
1317		SPOKENTIME  = f"{n: 3d} Millennia, {c: 4d} Century, {y: 3d} Years, {d: 4d} Days, {h: 2d} Hours, {m: 2d} Minutes, {s: 2d} Seconds, {ms: 3d} MilliSeconds, {μs: 3d} MicroSeconds, {ns: 3d} NanoSeconds"
1318		return TIMELAPSED, SPOKENTIME

REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 Convert NanoSeconds to Human Readable Time Format. A NanoSeconds is a unit of time in the International System of Units (SI) equal to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. Its symbol is μs, sometimes simplified to us when Unicode is not available. A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.

INPUT : ms (AKA: MilliSeconds) OUTPUT: tuple(string TIMELAPSED, string SPOKENTIME) like format. OUTPUT Variables: TIMELAPSED, SPOKENTIME

Example Input: DurationFromNanoSeconds(ns) "Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds" Example Output: ('039:0001:047:325:05:02:03:456:789:012', ' 39 Millennia, 1 Century, 47 Years, 325 Days, 5 Hours, 2 Minutes, 3 Seconds, 456 MilliSeconds, 789 MicroSeconds, 12 NanoSeconds') DurationFromNanoSeconds(1234567890123456789012)

@staticmethod
def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:
1320	@staticmethod
1321	def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024
1322		"""
1323		Convert a specific day, month, and year into a timestamp.
1324
1325		Parameters:
1326		day (int): The day of the month.
1327		month (int): The month of the year. Default is 6 (June).
1328		year (int): The year. Default is 2024.
1329
1330		Returns:
1331		int: The timestamp representing the given day, month, and year.
1332
1333		Note:
1334		This method assumes the default month and year if not provided.
1335		"""
1336		return ZakatTracker.time(datetime.datetime(year, month, day))

Convert a specific day, month, and year into a timestamp.

Parameters: day (int): The day of the month. month (int): The month of the year. Default is 6 (June). year (int): The year. Default is 2024.

Returns: int: The timestamp representing the given day, month, and year.

Note: This method assumes the default month and year if not provided.

@staticmethod
def generate_random_date( start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1338	@staticmethod
1339	def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1340		"""
1341		Generate a random date between two given dates.
1342
1343		Parameters:
1344		start_date (datetime.datetime): The start date from which to generate a random date.
1345		end_date (datetime.datetime): The end date until which to generate a random date.
1346
1347		Returns:
1348		datetime.datetime: A random date between the start_date and end_date.
1349		"""
1350		time_between_dates = end_date - start_date
1351		days_between_dates = time_between_dates.days
1352		random_number_of_days = random.randrange(days_between_dates)
1353		return start_date + datetime.timedelta(days=random_number_of_days)

Generate a random date between two given dates.

Parameters: start_date (datetime.datetime): The start date from which to generate a random date. end_date (datetime.datetime): The end date until which to generate a random date.

Returns: datetime.datetime: A random date between the start_date and end_date.

@staticmethod
def generate_random_csv_file(path: str = 'data.csv', count: int = 1000) -> None:
1355	@staticmethod
1356	def generate_random_csv_file(path: str = "data.csv", count: int = 1000) -> None:
1357		"""
1358		Generate a random CSV file with specified parameters.
1359
1360		Parameters:
1361		path (str): The path where the CSV file will be saved. Default is "data.csv".
1362		count (int): The number of rows to generate in the CSV file. Default is 1000.
1363
1364		Returns:
1365		None. The function generates a CSV file at the specified path with the given count of rows.
1366		Each row contains a randomly generated account, description, value, and date.
1367		The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31.
1368		If the row number is not divisible by 13, the value is multiplied by -1.
1369		"""
1370		with open(path, "w", newline="") as csvfile:
1371			writer = csv.writer(csvfile)
1372			for i in range(count):
1373				account = f"acc-{random.randint(1, 1000)}"
1374				desc = f"Some text {random.randint(1, 1000)}"
1375				value = random.randint(1000, 100000)
1376				date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1377				if not i % 13 == 0:
1378					value *= -1
1379				writer.writerow([account, desc, value, date])

Generate a random CSV file with specified parameters.

Parameters: path (str): The path where the CSV file will be saved. Default is "data.csv". count (int): The number of rows to generate in the CSV file. Default is 1000.

Returns: None. The function generates a CSV file at the specified path with the given count of rows. Each row contains a randomly generated account, description, value, and date. The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31. If the row number is not divisible by 13, the value is multiplied by -1.

@staticmethod
def create_random_list(max_sum, min_value=0, max_value=10):
1381	@staticmethod
1382	def create_random_list(max_sum, min_value=0, max_value=10):
1383		"""
1384		Creates a list of random integers whose sum does not exceed the specified maximum.
1385
1386		Args:
1387			max_sum: The maximum allowed sum of the list elements.
1388			min_value: The minimum possible value for an element (inclusive).
1389			max_value: The maximum possible value for an element (inclusive).
1390
1391		Returns:
1392			A list of random integers.
1393		"""
1394		result = []
1395		current_sum = 0
1396
1397		while current_sum < max_sum:
1398			# Calculate the remaining space for the next element
1399			remaining_sum = max_sum - current_sum
1400			# Determine the maximum possible value for the next element
1401			next_max_value = min(remaining_sum, max_value)
1402			# Generate a random element within the allowed range
1403			next_element = random.randint(min_value, next_max_value)
1404			result.append(next_element)
1405			current_sum += next_element
1406
1407		return result

Creates a list of random integers whose sum does not exceed the specified maximum.

Args: max_sum: The maximum allowed sum of the list elements. min_value: The minimum possible value for an element (inclusive). max_value: The maximum possible value for an element (inclusive).

Returns: A list of random integers.

def test(self, debug: bool = False):
1547	def test(self, debug: bool = False):
1548
1549		try:
1550
1551			assert self._history()
1552
1553			# Not allowed for duplicate transactions in the same account and time
1554
1555			created = ZakatTracker.time()
1556			self.track(100, 'test-1', 'same', True, created)
1557			failed = False
1558			try:
1559				self.track(50, 'test-1', 'same', True, created)
1560			except:
1561				failed = True
1562			assert failed is True
1563
1564			self.reset()
1565
1566			# Always preserve box age during transfer
1567
1568			created = ZakatTracker.time()
1569			series = [
1570				(30, 4),
1571				(60, 3),
1572				(90, 2),
1573			]
1574			case = {
1575				30: {
1576					'series': series,
1577					'rest' : 150,
1578				},
1579				60: {
1580					'series': series,
1581					'rest' : 120,
1582				},
1583				90: {
1584					'series': series,
1585					'rest' : 90,
1586				},
1587				180: {
1588					'series': series,
1589					'rest' : 0,
1590				},
1591				270: {
1592					'series': series,
1593					'rest' : -90,
1594				},
1595				360: {
1596					'series': series,
1597					'rest' : -180,
1598				},
1599			}
1600
1601			selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
1602				
1603			for total in case:
1604				for x in case[total]['series']:
1605					self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
1606
1607				refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
1608
1609				if debug:
1610					print('refs', refs)
1611
1612				ages_cache_balance = self.balance('ages')
1613				ages_fresh_balance = self.balance('ages', False)
1614				rest = case[total]['rest']
1615				if debug:
1616					print('source', ages_cache_balance, ages_fresh_balance, rest)
1617				assert ages_cache_balance == rest
1618				assert ages_fresh_balance == rest
1619
1620				future_cache_balance = self.balance('future')
1621				future_fresh_balance = self.balance('future', False)
1622				if debug:
1623					print('target', future_cache_balance, future_fresh_balance, total)
1624					print('refs', refs)
1625				assert future_cache_balance == total
1626				assert future_fresh_balance == total
1627
1628				for ref in self._vault['account']['ages']['box']:
1629					ages_capital = self._vault['account']['ages']['box'][ref]['capital']
1630					ages_rest = self._vault['account']['ages']['box'][ref]['rest']
1631					future_capital = 0
1632					if ref in self._vault['account']['future']['box']:
1633						future_capital = self._vault['account']['future']['box'][ref]['capital']
1634					future_rest = 0
1635					if ref in self._vault['account']['future']['box']:
1636						future_rest = self._vault['account']['future']['box'][ref]['rest']
1637					if ages_capital != 0 and future_capital != 0 and future_rest != 0:
1638						if debug:
1639							print('================================================================')
1640							print('ages', ages_capital, ages_rest)
1641							print('future', future_capital, future_rest)
1642						if ages_rest == 0:
1643							assert ages_capital == future_capital
1644						elif ages_rest < 0:
1645							assert -ages_capital == future_capital
1646						elif ages_rest > 0:
1647							assert ages_capital == ages_rest + future_capital
1648				self.reset()
1649				assert len(self._vault['history']) == 0
1650
1651			assert self._history()
1652			assert self._history(False) is False
1653			assert self._history() is False
1654			assert self._history(True)
1655			assert self._history()
1656
1657			self._test_core(True, debug)
1658			self._test_core(False, debug)
1659
1660			transaction = [
1661				(
1662					20, 'wallet', 1, 800, 800, 800, 4, 5,
1663									-85, -85, -85, 6, 7,
1664				),
1665				(
1666					750, 'wallet', 'safe',  50,   50,  50, 4, 6,
1667											750, 750, 750, 1, 1,
1668				),
1669				(
1670					600, 'safe', 'bank', 150, 150, 150, 1, 2,
1671										600, 600, 600, 1, 1,
1672				),
1673			]
1674			for z in transaction:
1675				self.lock()
1676				x = z[1]
1677				y = z[2]
1678				self.transfer(z[0], x, y, 'test-transfer', debug=debug)
1679				assert self.balance(x) == z[3]
1680				xx = self.accounts()[x]
1681				assert xx == z[3]
1682				assert self.balance(x, False) == z[4]
1683				assert xx == z[4]
1684
1685				l = self._vault['account'][x]['log']
1686				s = 0
1687				for i in l:
1688					s += l[i]['value']
1689				if debug:
1690					print('s', s, 'z[5]', z[5])
1691				assert s == z[5]
1692
1693				assert self.box_size(x) == z[6]
1694				assert self.log_size(x) == z[7]
1695
1696				yy = self.accounts()[y]
1697				assert self.balance(y) == z[8]
1698				assert yy == z[8]
1699				assert self.balance(y, False) == z[9]
1700				assert yy == z[9]
1701
1702				l = self._vault['account'][y]['log']
1703				s = 0
1704				for i in l:
1705					s += l[i]['value']
1706				assert s == z[10]
1707
1708				assert self.box_size(y) == z[11]
1709				assert self.log_size(y) == z[12]
1710
1711			if debug:
1712				pp().pprint(self.check(2.17))
1713
1714			assert not self.nolock()
1715			history_count = len(self._vault['history'])
1716			if debug:
1717				print('history-count', history_count)
1718			assert history_count == 11
1719			assert not self.free(ZakatTracker.time())
1720			assert self.free(self.lock())
1721			assert self.nolock()
1722			assert len(self._vault['history']) == 11
1723
1724			# storage
1725
1726			_path = self.path('test.pickle')
1727			if os.path.exists(_path):
1728				os.remove(_path)
1729			self.save()
1730			assert os.path.getsize(_path) > 0
1731			self.reset()
1732			assert self.recall(False, debug) is False
1733			self.load()
1734			assert self._vault['account'] is not None
1735
1736			# recall
1737
1738			assert self.nolock()
1739			assert len(self._vault['history']) == 11
1740			assert self.recall(False, debug) is True
1741			assert len(self._vault['history']) == 10
1742			assert self.recall(False, debug) is True
1743			assert len(self._vault['history']) == 9
1744
1745			# csv
1746			
1747			_path = "test.csv"
1748			count = 1000
1749			if os.path.exists(_path):
1750				os.remove(_path)
1751			self.generate_random_csv_file(_path, count)
1752			assert os.path.getsize(_path) > 0
1753			tmp = "tmp"
1754			if os.path.exists(tmp):
1755				os.remove(tmp)
1756			(created, found, bad) = self.import_csv(_path)
1757			bad_count = len(bad)
1758			if debug:
1759				print(f"csv-imported: ({created}, {found}, {bad_count})")
1760			tmp_size = os.path.getsize(tmp)
1761			assert tmp_size > 0
1762			assert created + found + bad_count == count
1763			assert created == count
1764			assert bad_count == 0
1765			(created_2, found_2, bad_2) = self.import_csv(_path)
1766			bad_2_count = len(bad_2)
1767			if debug:
1768				print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
1769				print(bad)
1770			assert tmp_size == os.path.getsize(tmp)
1771			assert created_2 + found_2 + bad_2_count == count
1772			assert created == found_2
1773			assert bad_count == bad_2_count
1774			assert found_2 == count
1775			assert bad_2_count == 0
1776			assert created_2 == 0
1777
1778			# payment parts
1779
1780			positive_parts = self.build_payment_parts(100, positive_only=True)
1781			assert self.check_payment_parts(positive_parts) != 0
1782			assert self.check_payment_parts(positive_parts) != 0
1783			all_parts = self.build_payment_parts(300, positive_only= False)
1784			assert self.check_payment_parts(all_parts) != 0
1785			assert self.check_payment_parts(all_parts) != 0
1786			if debug:
1787				pp().pprint(positive_parts)
1788				pp().pprint(all_parts)
1789			# dynamic discount
1790			suite = []
1791			count = 3
1792			for exceed in [False, True]:
1793				case = []
1794				for parts in [positive_parts, all_parts]:
1795					part = parts.copy()
1796					demand = part['demand']
1797					if debug:
1798						print(demand, part['total'])
1799					i = 0
1800					z = demand / count
1801					cp = {
1802						'account': {},
1803						'demand': demand,
1804						'exceed': exceed,
1805						'total': part['total'],
1806					}
1807					for x, y in part['account'].items():
1808						if exceed and z <= demand:
1809							i += 1
1810							y['part'] = z
1811							if debug:
1812								print(exceed, y)
1813							cp['account'][x] = y
1814							case.append(y)
1815						elif not exceed and y['balance'] >= z:
1816								i += 1
1817								y['part'] = z
1818								if debug:
1819									print(exceed, y)
1820								cp['account'][x] = y
1821								case.append(y)
1822						if i >= count:
1823								break
1824					if len(cp['account'][x]) > 0:
1825						suite.append(cp)
1826			if debug:
1827				print('suite', len(suite))
1828			for case in suite:
1829				if debug:
1830					print(case)
1831				result = self.check_payment_parts(case)
1832				if debug:
1833					print('check_payment_parts', result)
1834				assert result == 0
1835				
1836			report = self.check(2.17, None, debug)
1837			(valid, brief, plan) = report
1838			if debug:
1839				print('valid', valid)
1840			assert self.zakat(report, parts=suite, debug=debug)
1841
1842			# exchange
1843
1844			self.exchange("cash", 25, 3.75, "2024-06-25")
1845			self.exchange("cash", 22, 3.73, "2024-06-22")
1846			self.exchange("cash", 15, 3.69, "2024-06-15")
1847			self.exchange("cash", 10, 3.66)
1848
1849			for i in range(1, 30):
1850				rate, description = self.exchange("cash", i).values()
1851				if debug:
1852					print(i, rate, description)
1853				if i < 10:
1854					assert rate == 1
1855					assert description is None
1856				elif i == 10:
1857					assert rate == 3.66
1858					assert description is None
1859				elif i < 15:
1860					assert rate == 3.66
1861					assert description is None
1862				elif i == 15:
1863					assert rate == 3.69
1864					assert description is not None
1865				elif i < 22:
1866					assert rate == 3.69
1867					assert description is not None
1868				elif i == 22:
1869					assert rate == 3.73
1870					assert description is not None
1871				elif i >= 25:
1872					assert rate == 3.75
1873					assert description is not None
1874				rate, description = self.exchange("bank", i).values()
1875				if debug:
1876					print(i, rate, description)
1877				assert rate == 1
1878				assert description is None
1879
1880			assert len(self._vault['exchange']) > 0
1881			assert len(self.exchanges()) > 0
1882			self._vault['exchange'].clear()
1883			assert len(self._vault['exchange']) == 0
1884			assert len(self.exchanges()) == 0
1885
1886			# حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
1887			self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
1888			self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
1889			self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
1890			self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
1891
1892			for i in [x * 0.12 for x in range(-15, 21)]:
1893				if i <= 1:
1894					assert self.exchange("test", ZakatTracker.time(), i, f"range({i})") is None
1895				else:
1896					assert self.exchange("test", ZakatTracker.time(), i, f"range({i})") is not None
1897
1898			# اختبار النتائج باستخدام التواريخ بالنانو ثانية
1899			for i in range(1, 31):
1900				timestamp_ns = ZakatTracker.day_to_time(i)
1901				rate, description = self.exchange("cash", timestamp_ns).values()
1902				print(i, rate, description)
1903				if i < 10:
1904					assert rate == 1
1905					assert description is None
1906				elif i == 10:
1907					assert rate == 3.66
1908					assert description is None
1909				elif i < 15:
1910					assert rate == 3.66
1911					assert description is None
1912				elif i == 15:
1913					assert rate == 3.69
1914					assert description is not None
1915				elif i < 22:
1916					assert rate == 3.69
1917					assert description is not None
1918				elif i == 22:
1919					assert rate == 3.73
1920					assert description is not None
1921				elif i >= 25:
1922					assert rate == 3.75
1923					assert description is not None
1924				rate, description = self.exchange("bank", i).values()
1925				print(i, rate, description)
1926				assert rate == 1
1927				assert description is None
1928
1929			assert self.export_json("1000-transactions-test.json")
1930			assert self.save("1000-transactions-test.pickle")
1931
1932			self.reset()
1933
1934			# test transfer between accounts with different exchange rate
1935
1936			debug = True
1937			a_SAR = "Bank (SAR)"
1938			b_USD = "Bank (USD)"
1939			c_SAR = "Safe (SAR)"
1940			# 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
1941			for case in[
1942				(0, a_SAR, "SAR Gift", 1000, 1000),
1943				(1, a_SAR, 1),
1944				(0, b_USD, "USD Gift", 500, 500),
1945				(1, b_USD, 1),
1946				(2, b_USD, 3.75),
1947				(1, b_USD, 3.75),
1948				(3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
1949				(0, c_SAR, "Salary", 750, 750),
1950				(3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
1951				(3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
1952			]:
1953				match(case[0]):
1954					case 0: # track
1955						_, account, desc, x, balance = case
1956						self.track(value=x, desc=desc, account=account, debug=debug)
1957
1958						cached_value = self.balance(account, cached=True)
1959						fresh_value = self.balance(account, cached=False)
1960						if debug:
1961							print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
1962						assert cached_value == balance
1963						assert fresh_value == balance
1964					case 1: # check-exchange
1965						_, account, expected_rate = case
1966						t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
1967						if debug:
1968							print('t-exchange', t_exchange)
1969						assert t_exchange['rate'] == expected_rate
1970					case 2: # do-exchange
1971						_, account, rate = case
1972						self.exchange(account, rate=rate, debug=debug)
1973						b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
1974						if debug:
1975							print('b-exchange', b_exchange)
1976						assert b_exchange['rate'] == rate
1977					case 3: # transfer
1978						_, x, a, b, desc, a_balance, b_balance = case
1979						self.transfer(x, a, b, desc, debug=debug)
1980
1981						cached_value = self.balance(a, cached=True)
1982						fresh_value = self.balance(a, cached=False)
1983						if debug:
1984							print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
1985						assert cached_value == a_balance
1986						assert fresh_value == a_balance
1987
1988						cached_value = self.balance(b, cached=True)
1989						fresh_value = self.balance(b, cached=False)
1990						if debug:
1991							print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
1992						assert cached_value == b_balance
1993						assert fresh_value == b_balance
1994
1995			# Transfer all in many chunks randomly from B to A
1996			a_SAR_balance = 1371.25
1997			b_USD_balance = 501
1998			b_USD_exchange = self.exchange(b_USD)
1999			amounts = ZakatTracker.create_random_list(b_USD_balance)
2000			if debug:
2001				print('amounts', amounts)
2002			i = 0
2003			for x in amounts:
2004				if debug:
2005					print(f'{i} - transfer-with-exchnage({x})')
2006				self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2007				
2008				b_USD_balance -= x
2009				cached_value = self.balance(b_USD, cached=True)
2010				fresh_value = self.balance(b_USD, cached=False)
2011				if debug:
2012					print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', b_USD_balance)
2013				assert cached_value == b_USD_balance
2014				assert fresh_value == b_USD_balance
2015
2016				a_SAR_balance += x * b_USD_exchange['rate']
2017				cached_value = self.balance(a_SAR, cached=True)
2018				fresh_value = self.balance(a_SAR, cached=False)
2019				if debug:
2020					print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', a_SAR_balance, 'rate', b_USD_exchange['rate'])
2021				assert cached_value == a_SAR_balance
2022				assert fresh_value == a_SAR_balance
2023				i += 1
2024			
2025			# Transfer all in many chunks randomly from C to A
2026			c_SAR_balance = 375
2027			amounts = ZakatTracker.create_random_list(c_SAR_balance)
2028			if debug:
2029				print('amounts', amounts)
2030			i = 0
2031			for x in amounts:
2032				if debug:
2033					print(f'{i} - transfer-with-exchnage({x})')
2034				self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2035				
2036				c_SAR_balance -= x
2037				cached_value = self.balance(c_SAR, cached=True)
2038				fresh_value = self.balance(c_SAR, cached=False)
2039				if debug:
2040					print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', c_SAR_balance)
2041				assert cached_value == c_SAR_balance
2042				assert fresh_value == c_SAR_balance
2043
2044				a_SAR_balance += x
2045				cached_value = self.balance(a_SAR, cached=True)
2046				fresh_value = self.balance(a_SAR, cached=False)
2047				if debug:
2048					print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', a_SAR_balance)
2049				assert cached_value == a_SAR_balance
2050				assert fresh_value == a_SAR_balance
2051				i += 1
2052
2053			assert self.export_json("accounts-transfer-with-exchange-rates.json")
2054			assert self.save("accounts-transfer-with-exchange-rates.pickle")
2055
2056			# check & zakat
2057
2058			cases = [
2059				(1000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [
2060					{'safe': {0: {'below_nisab': 25}}},
2061				], False),
2062				(2000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [
2063					{'safe': {0: {'count': 1, 'total': 50}}},
2064				], True),
2065				(10000, 'cave', ZakatTracker.time()-(ZakatTracker.TimeCycle()*3), [
2066					{'cave': {0: {'count': 3, 'total': 731.40625}}},
2067				], True),
2068			]
2069			for case in cases:
2070				if debug:
2071					print("############# check #############")
2072				self.reset()
2073				self.track(case[0], 'test-check', case[1], True, case[2])
2074
2075				assert self.nolock()
2076				assert len(self._vault['history']) == 1
2077				assert self.lock()
2078				assert not self.nolock()
2079				report = self.check(2.17, None, debug)
2080				(valid, brief, plan) = report
2081				assert valid == case[4]
2082				if debug:
2083					print(brief)
2084				assert case[0] == brief[0]
2085				assert case[0] == brief[1]
2086
2087				if debug:
2088					pp().pprint(plan)
2089
2090				for x in plan:
2091					assert case[1] == x
2092					if 'total' in case[3][0][x][0].keys():
2093						assert case[3][0][x][0]['total'] == brief[2]
2094						assert plan[x][0]['total'] == case[3][0][x][0]['total']
2095						assert plan[x][0]['count'] == case[3][0][x][0]['count']
2096					else:
2097						assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2098				if debug:
2099					pp().pprint(report)
2100				result = self.zakat(report, debug=debug)
2101				if debug:
2102					print('zakat-result', result, case[4])
2103				assert result == case[4]
2104				report = self.check(2.17, None, debug)
2105				(valid, brief, plan) = report
2106				assert valid is False
2107
2108			assert len(self._vault['history']) == 2
2109			assert not self.nolock()
2110			assert self.recall(False, debug) is False
2111			self.free(self.lock())
2112			assert self.nolock()
2113			assert len(self._vault['history']) == 2
2114			assert self.recall(False, debug) is True
2115			assert len(self._vault['history']) == 1
2116
2117			assert self.nolock()
2118			assert len(self._vault['history']) == 1
2119
2120			assert self.recall(False, debug) is True
2121			assert len(self._vault['history']) == 0
2122
2123			assert len(self._vault['account']) == 0
2124			assert len(self._vault['history']) == 0
2125			assert len(self._vault['report']) == 0
2126			assert self.nolock()
2127		except:
2128			# pp().pprint(self._vault)
2129			assert self.export_json("test-snapshot.json")
2130			assert self.save("test-snapshot.pickle")
2131			raise
class Action(enum.Enum):
68class Action(Enum):
69	CREATE = auto()
70	TRACK = auto()
71	LOG = auto()
72	SUB = auto()
73	ADD_FILE = auto()
74	REMOVE_FILE = auto()
75	BOX_TRANSFER = auto()
76	EXCHANGE = auto()
77	REPORT = auto()
78	ZAKAT = auto()
CREATE = <Action.CREATE: 1>
TRACK = <Action.TRACK: 2>
LOG = <Action.LOG: 3>
SUB = <Action.SUB: 4>
ADD_FILE = <Action.ADD_FILE: 5>
REMOVE_FILE = <Action.REMOVE_FILE: 6>
BOX_TRANSFER = <Action.BOX_TRANSFER: 7>
EXCHANGE = <Action.EXCHANGE: 8>
REPORT = <Action.REPORT: 9>
ZAKAT = <Action.ZAKAT: 10>
class JSONEncoder(json.encoder.JSONEncoder):
80class JSONEncoder(json.JSONEncoder):
81	def default(self, obj):
82		if isinstance(obj, Action) or isinstance(obj, MathOperation):
83			return obj.name  # Serialize as the enum member's name
84		return super().default(obj)

Extensible JSON https://json.org encoder for Python data structures.

Supports the following objects and types by default:

+-------------------+---------------+ | Python | JSON | +===================+===============+ | dict | object | +-------------------+---------------+ | list, tuple | array | +-------------------+---------------+ | str | string | +-------------------+---------------+ | int, float | number | +-------------------+---------------+ | True | true | +-------------------+---------------+ | False | false | +-------------------+---------------+ | None | null | +-------------------+---------------+

To extend this to recognize other objects, subclass and implement a .default() method with another method that returns a serializable object for o if possible, otherwise it should call the superclass implementation (to raise TypeError).

def default(self, obj):
81	def default(self, obj):
82		if isinstance(obj, Action) or isinstance(obj, MathOperation):
83			return obj.name  # Serialize as the enum member's name
84		return super().default(obj)

Implement this method in a subclass such that it returns a serializable object for o, or calls the base implementation (to raise a TypeError).

For example, to support arbitrary iterators, you could implement default like this::

def default(self, o):
    try:
        iterable = iter(o)
    except TypeError:
        pass
    else:
        return list(iterable)
    # Let the base class default method raise the TypeError
    return super().default(o)
class MathOperation(enum.Enum):
86class MathOperation(Enum):
87	ADDITION = auto()
88	EQUAL = auto()
89	SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>