zakat
xxx

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

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

This module provides the ZakatTracker class for tracking and calculating Zakat.

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

@staticmethod
def time_to_datetime( ordinal_ns: int) -> <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'>:
235	@staticmethod
236	def time_to_datetime(ordinal_ns: int) -> datetime:
237		"""
238        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
239
240        Parameters:
241        ordinal_ns (int): The ordinal number of days since 1000-01-01.
242
243        Returns:
244        datetime: The corresponding datetime object.
245        """
246		ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
247		ns_in_day = ordinal_ns % 86_400_000_000_000
248		d = datetime.datetime.fromordinal(ordinal_day)
249		t = datetime.timedelta(seconds=ns_in_day // 10**9)
250		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:
286	def nolock(self) -> bool:
287		"""
288        Check if the vault lock is currently not set.
289
290        :return: True if the vault lock is not set, False otherwise.
291        """
292		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:
294	def lock(self) -> int:
295		"""
296        Acquires a lock on the ZakatTracker instance.
297
298        Returns:
299        int: The lock ID. This ID can be used to release the lock later.
300        """
301		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:
303	def box(self) -> dict:
304		"""
305        Returns a copy of the internal vault dictionary.
306
307        This method is used to retrieve the current state of the ZakatTracker object.
308        It provides a snapshot of the internal data structure, allowing for further
309        processing or analysis.
310
311        :return: A copy of the internal vault dictionary.
312        """
313		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:
315	def steps(self) -> dict:
316		"""
317        Returns a copy of the history of steps taken in the ZakatTracker.
318
319        The history is a dictionary where each key is a unique identifier for a step,
320        and the corresponding value is a dictionary containing information about the step.
321
322        :return: A copy of the history of steps taken in the ZakatTracker.
323        """
324		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:
326	def free(self, _lock: int, auto_save: bool = True) -> bool:
327		"""
328        Releases the lock on the database.
329
330        Parameters:
331        _lock (int): The lock ID to be released.
332        auto_save (bool): Whether to automatically save the database after releasing the lock.
333
334        Returns:
335        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
336        """
337		if _lock == self._vault['lock']:
338			self._vault['lock'] = None
339			if auto_save:
340				return self.save(self.path())
341			return True
342		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:
344	def account_exists(self, account) -> bool:
345		"""
346        Check if the given account exists in the vault.
347
348        Parameters:
349        account (str): The account number to check.
350
351        Returns:
352        bool: True if the account exists, False otherwise.
353        """
354		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:
356	def box_size(self, account) -> int:
357		"""
358		Calculate the size of the box for a specific account.
359
360		Parameters:
361		account (str): The account number for which the box size needs to be calculated.
362
363		Returns:
364		int: The size of the box for the given account. If the account does not exist, -1 is returned.
365		"""
366		if self.account_exists(account):
367			return len(self._vault['account'][account]['box'])
368		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:
370	def log_size(self, account) -> int:
371		"""
372        Get the size of the log for a specific account.
373
374        Parameters:
375        account (str): The account number for which the log size needs to be calculated.
376
377        Returns:
378        int: The size of the log for the given account. If the account does not exist, -1 is returned.
379        """
380		if self.account_exists(account):
381			return len(self._vault['account'][account]['log'])
382		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:
384	def recall(self, dry = True, debug = False) -> bool:
385		"""
386		Revert the last operation.
387
388		Parameters:
389		dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
390		debug (bool): If True, the function will print debug information. Default is False.
391
392		Returns:
393		bool: True if the operation was successful, False otherwise.
394		"""
395		if not self.nolock() or len(self._vault['history']) == 0:
396			return False
397		if len(self._vault['history']) <= 0:
398			return
399		ref = sorted(self._vault['history'].keys())[-1]
400		if debug:
401			print('recall', ref)
402		memory = self._vault['history'][ref]
403		if debug:
404			print(type(memory), 'memory', memory)
405
406		limit = len(memory) + 1
407		sub_positive_log_negative = 0
408		for i in range(-1, -limit, -1):
409			x = memory[i]
410			if debug:
411				print(type(x), x)
412			match x['action']:
413				case Action.CREATE:
414					if x['account'] is not None:
415						if self.account_exists(x['account']):
416							if debug:
417								print('account', self._vault['account'][x['account']])
418							assert len(self._vault['account'][x['account']]['box']) == 0
419							assert self._vault['account'][x['account']]['balance'] == 0
420							assert self._vault['account'][x['account']]['count'] == 0
421							if dry:
422								continue
423							del self._vault['account'][x['account']]
424
425				case Action.TRACK:
426					if x['account'] is not None:
427						if self.account_exists(x['account']):
428							if dry:
429								continue
430							self._vault['account'][x['account']]['balance'] -= x['value']
431							self._vault['account'][x['account']]['count'] -= 1
432							del self._vault['account'][x['account']]['box'][x['ref']]
433
434				case Action.LOG:
435					if x['account'] is not None:
436						if self.account_exists(x['account']):
437							if x['ref'] in self._vault['account'][x['account']]['log']:
438								if dry:
439									continue
440								if sub_positive_log_negative == -x['value']:
441									self._vault['account'][x['account']]['count'] -= 1
442									sub_positive_log_negative = 0
443								del self._vault['account'][x['account']]['log'][x['ref']]
444
445				case Action.SUB:
446					if x['account'] is not None:
447						if self.account_exists(x['account']):
448							if x['ref'] in self._vault['account'][x['account']]['box']:
449								if dry:
450									continue
451								self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
452								self._vault['account'][x['account']]['balance'] += x['value']
453								sub_positive_log_negative = x['value']
454
455				case Action.ADD_FILE:
456					if x['account'] is not None:
457						if self.account_exists(x['account']):
458							if x['ref'] in self._vault['account'][x['account']]['log']:
459								if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
460									if dry:
461										continue
462									del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
463
464				case Action.REMOVE_FILE:
465					if x['account'] is not None:
466						if self.account_exists(x['account']):
467							if x['ref'] in self._vault['account'][x['account']]['log']:
468								if dry:
469									continue
470								self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
471
472				case Action.REPORT:
473					if x['ref'] in self._vault['report']:
474						if dry:
475							continue
476						del self._vault['report'][x['ref']]
477
478				case Action.ZAKAT:
479					if x['account'] is not None:
480						if self.account_exists(x['account']):
481							if x['ref'] in self._vault['account'][x['account']]['box']:
482								if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
483									if dry:
484										continue
485									match x['math']:
486										case MathOperation.ADDITION:
487											self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x['value']
488										case MathOperation.EQUAL:
489											self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
490										case MathOperation.SUBTRACTION:
491											self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x['value']
492
493		if not dry:
494			del self._vault['history'][ref]
495		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 track( self, value: int = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
497	def track(self, value: int = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
498		"""
499        This function tracks a transaction for a specific account.
500
501        Parameters:
502        value (int): The value of the transaction. Default is 0.
503        desc (str): The description of the transaction. Default is an empty string.
504        account (str): The account for which the transaction is being tracked. Default is '1'.
505        logging (bool): Whether to log the transaction. Default is True.
506        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
507        debug (bool): Whether to print debug information. Default is False.
508
509        Returns:
510        int: The timestamp of the transaction.
511
512        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.
513        """
514		if created is None:
515			created = ZakatTracker.time()
516		_nolock = self.nolock(); self.lock()
517		if not self.account_exists(account):
518			if debug:
519				print(f"account {account} created")
520			self._vault['account'][account] = {
521				'balance': 0,
522				'box': {},
523				'count': 0,
524				'log': {},
525				'zakatable': True,
526			}
527			self._step(Action.CREATE, account)
528		if value == 0:
529			if _nolock: self.free(self.lock())
530			return 0
531		if logging:
532			self._log(value, desc, account, created)
533		assert created not in self._vault['account'][account]['box']
534		self._vault['account'][account]['box'][created] = {
535			'capital': value,
536			'count': 0,
537			'last': 0,
538			'rest': value,
539			'total': 0,
540		}
541		self._step(Action.TRACK, account, ref=created, value=value)
542		if _nolock: self.free(self.lock())
543		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.

def accounts(self) -> dict:
574	def accounts(self) -> dict:
575		"""
576        Returns a dictionary containing account numbers as keys and their respective balances as values.
577
578        Parameters:
579        None
580
581        Returns:
582        dict: A dictionary where keys are account numbers and values are their respective balances.
583        """
584		result = {}
585		for i in self._vault['account']:
586			result[i] = self._vault['account'][i]['balance']
587		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:
589	def boxes(self, account) -> dict:
590		"""
591        Retrieve the boxes (transactions) associated with a specific account.
592
593        Parameters:
594        account (str): The account number for which to retrieve the boxes.
595
596        Returns:
597        dict: A dictionary containing the boxes associated with the given account.
598        If the account does not exist, an empty dictionary is returned.
599        """
600		if self.account_exists(account):
601			return self._vault['account'][account]['box']
602		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:
604	def logs(self, account) -> dict:
605		"""
606		Retrieve the logs (transactions) associated with a specific account.
607
608		Parameters:
609		account (str): The account number for which to retrieve the logs.
610
611		Returns:
612		dict: A dictionary containing the logs associated with the given account.
613		If the account does not exist, an empty dictionary is returned.
614		"""
615		if self.account_exists(account):
616			return self._vault['account'][account]['log']
617		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:
619	def add_file(self, account: str, ref: int, _path: str) -> int:
620		"""
621		Adds a file reference to a specific transaction log entry in the vault.
622
623		Parameters:
624		account (str): The account number associated with the transaction log.
625		ref (int): The reference to the transaction log entry.
626		_path (str): The path of the file to be added.
627
628		Returns:
629		int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
630		"""
631		if self.account_exists(account):
632			if ref in self._vault['account'][account]['log']:
633				file_ref = ZakatTracker.time()
634				self._vault['account'][account]['log'][ref]['file'][file_ref] = _path
635				_nolock = self.nolock(); self.lock()
636				self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
637				if _nolock: self.free(self.lock())
638				return file_ref
639		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:
641	def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
642		"""
643		Removes a file reference from a specific transaction log entry in the vault.
644
645		Parameters:
646		account (str): The account number associated with the transaction log.
647		ref (int): The reference to the transaction log entry.
648		file_ref (int): The reference of the file to be removed.
649
650		Returns:
651		bool: True if the file reference is successfully removed, False otherwise.
652		"""
653		if self.account_exists(account):
654			if ref in self._vault['account'][account]['log']:
655				if file_ref in self._vault['account'][account]['log'][ref]['file']:
656					x = self._vault['account'][account]['log'][ref]['file'][file_ref]
657					del self._vault['account'][account]['log'][ref]['file'][file_ref]
658					_nolock = self.nolock(); self.lock()
659					self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
660					if _nolock: self.free(self.lock())
661					return True
662		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:
664	def balance(self, account: str = 1, cached: bool = True) -> int:
665		"""
666		Calculate and return the balance of a specific account.
667
668		Parameters:
669		account (str): The account number. Default is '1'.
670		cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
671
672		Returns:
673		int: The balance of the account.
674
675		Note:
676		If cached is True, the function returns the cached balance.
677		If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
678		"""
679		if cached:
680			return self._vault['account'][account]['balance']
681		x = 0
682		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:
684	def zakatable(self, account, status: bool = None) -> bool:
685		"""
686        Check or set the zakatable status of a specific account.
687
688        Parameters:
689        account (str): The account number.
690        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
691
692        Returns:
693        bool: The current or updated zakatable status of the account.
694
695        Raises:
696        None
697
698        Example:
699        >>> tracker = ZakatTracker()
700        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
701        True
702        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1'
703        True
704        """
705		if self.account_exists(account):
706			if status is None:
707				return self._vault['account'][account]['zakatable']
708			self._vault['account'][account]['zakatable'] = status
709			return status
710		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) -> int:
712	def sub(self, x: int, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> int:
713		"""
714		Subtracts a specified amount from the account's balance.
715
716		Parameters:
717		x (int): The amount to subtract.
718		desc (str): The description of the transaction.
719		account (str): The account from which to subtract.
720		created (int): The timestamp of the transaction.
721		debug (bool): A flag to enable debug mode.
722
723		Returns:
724		int: The timestamp of the transaction.
725
726		If the amount to subtract is greater than the account's balance,
727		the remaining amount will be transferred to a new transaction with a negative value.
728		"""
729		if x < 0:
730			return
731		if x == 0:
732			return self.track(x, '', account)
733		if created is None:
734			created = ZakatTracker.time()
735		_nolock = self.nolock(); self.lock()
736		self.track(0, '', account)
737		self._log(-x, desc, account, created)
738		ids = sorted(self._vault['account'][account]['box'].keys())
739		limit = len(ids) + 1
740		target = x
741		if debug:
742			print('ids', ids)
743		ages = []
744		for i in range(-1, -limit, -1):
745			if target == 0:
746				break
747			j = ids[i]
748			if debug:
749				print('i', i, 'j', j)
750			if self._vault['account'][account]['box'][j]['rest'] >= target:
751				self._vault['account'][account]['box'][j]['rest'] -= target
752				self._step(Action.SUB, account, ref=j, value=target)
753				ages.append((j, target))
754				target = 0
755				break
756			elif self._vault['account'][account]['box'][j]['rest'] < target and self._vault['account'][account]['box'][j]['rest'] > 0:
757				chunk = self._vault['account'][account]['box'][j]['rest']
758				target -= chunk
759				self._step(Action.SUB, account, ref=j, value=chunk)
760				ages.append((j, chunk))
761				self._vault['account'][account]['box'][j]['rest'] = 0
762		if target > 0:
763			self.track(-target, desc, account, False, created)
764			ages.append((created, target))
765		if _nolock: self.free(self.lock())
766		return (created, ages)

Subtracts a specified amount from the account's balance.

Parameters: x (int): The amount to subtract. desc (str): The description of the transaction. account (str): The account from which to subtract. created (int): The timestamp of the transaction. debug (bool): A flag to enable debug mode.

Returns: int: The timestamp of the 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.

def transfer( self, value: int, from_account: str, to_account: str, desc: str = '', created: int = None) -> list[int]:
768	def transfer(self, value: int, from_account: str, to_account: str, desc: str = '', created: int = None) -> list[int]:
769		"""
770		Transfers a specified value from one account to another.
771
772		Parameters:
773		value (int): The amount to be transferred.
774		from_account (str): The account from which the value will be transferred.
775		to_account (str): The account to which the value will be transferred.
776		desc (str, optional): A description for the transaction. Defaults to an empty string.
777		created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
778
779		Returns:
780		list[int]: A list of timestamps corresponding to the transactions made during the transfer.
781
782		Raises:
783		ValueError: If the value to be transferred is negative or if the value exceeds the balance in the from_account.
784		"""
785		if created is None:
786			created = ZakatTracker.time()
787		(_, ages) = self.sub(value, desc, from_account, created)
788		times = []
789		for age in ages:
790			y = self.track(age[1], desc, to_account, logging=True, created=age[0])
791			times.append(y)
792		return times

Transfers a specified value from one account to another.

Parameters: value (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.

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

Raises: ValueError: If the value to be transferred is negative or if the value exceeds the balance in the from_account.

def check( self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
794	def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
795		"""
796		Check the eligibility for Zakat based on the given parameters.
797
798		Parameters:
799		silver_gram_price (float): The price of a gram of silver.
800		nisab (float): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price.
801		debug (bool): Flag to enable debug mode.
802		now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
803		cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
804
805		Returns:
806		tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.
807		"""
808		if now is None:
809			now = ZakatTracker.time()
810		if cycle is None:
811			cycle = ZakatTracker.TimeCycle()
812		if nisab is None:
813			nisab = ZakatTracker.Nisab(silver_gram_price)
814		plan = {}
815		below_nisab = 0
816		brief = [0, 0, 0]
817		valid = False
818		for x in self._vault['account']:
819			if not self._vault['account'][x]['zakatable']:
820				continue
821			_box = self._vault['account'][x]['box']
822			limit = len(_box) + 1
823			ids = sorted(self._vault['account'][x]['box'].keys())
824			for i in range(-1, -limit, -1):
825				j = ids[i]
826				if _box[j]['rest'] <= 0:
827					continue
828				brief[0] += _box[j]['rest']
829				index = limit + i - 1
830				epoch = (now - j) / cycle
831				if debug:
832					print(f"Epoch: {epoch}", _box[j])
833				if _box[j]['last'] > 0:
834					epoch = (now - _box[j]['last']) / cycle
835				if debug:
836					print(f"Epoch: {epoch}")
837				epoch = floor(epoch)
838				if debug:
839					print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1-epoch, epoch)
840				if epoch == 0:
841					continue
842				if debug:
843					print("Epoch - PASSED")
844				brief[1] += _box[j]['rest']
845				if _box[j]['rest'] >= nisab:
846					total = 0
847					for _ in range(epoch):
848						total += ZakatTracker.ZakatCut(_box[j]['rest'] - total)
849					if total > 0:
850						if x not in plan:
851							plan[x] = {}
852						valid = True
853						brief[2] += total
854						plan[x][index] = {'total': total, 'count': epoch}
855				else:
856					chunk = ZakatTracker.ZakatCut(_box[j]['rest'])
857					if chunk > 0:
858						if x not in plan:
859							plan[x] = {}
860						if j not in plan[x].keys():
861							plan[x][index] = {}
862						below_nisab += _box[j]['rest']
863						brief[2] += chunk
864						plan[x][index]['below_nisab'] = chunk
865		valid = valid or below_nisab >= nisab
866		if debug:
867			print(f"below_nisab({below_nisab}) >= nisab({nisab})")
868		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:
870	def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
871		"""
872		Build payment parts for the zakat distribution.
873
874		Parameters:
875		demand (float): The total demand for payment.
876		positive_only (bool): If True, only consider accounts with positive balance. Default is True.
877
878		Returns:
879		dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
880		{
881			'account': {
882				'account_id': {'balance': float, 'part': float},
883				...
884			},
885			'exceed': bool,
886			'demand': float,
887			'total': float,
888		}
889		"""
890		total = 0
891		parts = {
892			'account': {},
893			'exceed': False,
894			'demand': demand,
895		}
896		for x, y in self.accounts().items():
897			if positive_only and y <= 0:
898				continue
899			total += y
900			parts['account'][x] = {'balance': y, 'part': 0}
901		parts['total'] = total
902		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:
904	def check_payment_parts(self, parts: dict) -> int:
905		"""
906        Checks the validity of payment parts.
907
908        Parameters:
909        parts (dict): A dictionary containing payment parts information.
910
911        Returns:
912        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
913
914        Error Codes:
915        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
916        2: 'balance' or 'part' key is missing in parts['account'][x].
917        3: 'part' value in parts['account'][x] is less than or equal to 0.
918        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
919        5: 'part' value in parts['account'][x] is less than 0.
920        6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
921        7: The sum of 'part' values in parts['account'] does not match with 'demand' value.
922        """
923		for i in ['demand', 'account', 'total', 'exceed']:
924			if not i in parts:
925				return 1
926		exceed = parts['exceed']
927		for x in parts['account']:
928			for j in ['balance', 'part']:
929				if not j in parts['account'][x]:
930					return 2
931				if parts['account'][x]['part'] <= 0:
932					return 3
933				if not exceed and parts['account'][x]['balance'] <= 0:
934					return 4
935		demand = parts['demand']
936		z = 0
937		for _, y in parts['account'].items():
938			if y['part'] < 0:
939				return 5
940			if not exceed and y['part'] > y['balance']:
941				return 6
942			z += y['part']
943		if z != demand:
944			return 7
945		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:
 947	def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool:
 948		"""
 949		Perform Zakat calculation based on the given report and optional parts.
 950
 951		Parameters:
 952		report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
 953		parts (dict): A dictionary containing the payment parts for the zakat.
 954		debug (bool): A flag indicating whether to print debug information.
 955
 956		Returns:
 957		bool: True if the zakat calculation is successful, False otherwise.
 958		"""
 959		(valid, _, plan) = report
 960		if not valid:
 961			return valid
 962		parts_exist = parts is not None
 963		if parts_exist:
 964			for part in parts:
 965				if self.check_payment_parts(part) != 0:
 966					return False
 967		if debug:
 968			print('######### zakat #######')
 969			print('parts_exist', parts_exist)
 970		_nolock = self.nolock(); self.lock()
 971		report_time = ZakatTracker.time()
 972		self._vault['report'][report_time] = report
 973		self._step(Action.REPORT, ref=report_time)
 974		created = ZakatTracker.time()
 975		for x in plan:
 976			if debug:
 977				print(plan[x])
 978				print('-------------')
 979				print(self._vault['account'][x]['box'])
 980			ids = sorted(self._vault['account'][x]['box'].keys())
 981			if debug:
 982				print('plan[x]', plan[x])
 983			for i in plan[x].keys():
 984				j = ids[i]
 985				if debug:
 986					print('i', i, 'j', j)
 987				self._step(Action.ZAKAT, x, j, value=self._vault['account'][x]['box'][j]['last'], key='last', math_operation=MathOperation.EQUAL)
 988				self._vault['account'][x]['box'][j]['last'] = created
 989				self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total']
 990				self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='total', math_operation=MathOperation.ADDITION)
 991				self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
 992				self._step(Action.ZAKAT, x, j, value=plan[x][i]['count'], key='count', math_operation=MathOperation.ADDITION)
 993				if not parts_exist:
 994					self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total']
 995					self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='rest', math_operation=MathOperation.SUBTRACTION)
 996		if parts_exist:
 997			for transaction in parts:
 998				for account, part in transaction['account'].items():
 999					if debug:
1000						print('zakat-part', account, part['part'])
1001					self.sub(part['part'], 'zakat-part', account, debug=debug)
1002		if _nolock: self.free(self.lock())
1003		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:
1005	def export_json(self, _path: str = "data.json") -> bool:
1006		"""
1007        Exports the current state of the ZakatTracker object to a JSON file.
1008
1009        Parameters:
1010        _path (str): The path where the JSON file will be saved. Default is "data.json".
1011
1012        Returns:
1013        bool: True if the export is successful, False otherwise.
1014
1015        Raises:
1016        No specific exceptions are raised by this method.
1017        """
1018		with open(_path, "w") as file:
1019			json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1020			return True
1021		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:
1023	def save(self, _path: str = None) -> bool:
1024		"""
1025		Save the current state of the ZakatTracker object to a pickle file.
1026
1027		Parameters:
1028		_path (str): The path where the pickle file will be saved. If not provided, it will use the default path.
1029
1030		Returns:
1031		bool: True if the save operation is successful, False otherwise.
1032		"""
1033		if _path is None:
1034			_path = self.path()
1035		with open(_path, "wb") as f:
1036			pickle.dump(self._vault, f)
1037			return True
1038		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:
1040	def load(self, _path: str = None) -> bool:
1041		"""
1042		Load the current state of the ZakatTracker object from a pickle file.
1043
1044		Parameters:
1045		_path (str): The path where the pickle file is located. If not provided, it will use the default path.
1046
1047		Returns:
1048		bool: True if the load operation is successful, False otherwise.
1049		"""
1050		if _path is None:
1051			_path = self.path()
1052		if os.path.exists(_path):
1053			with open(_path, "rb") as f:
1054				self._vault = pickle.load(f)
1055				return True
1056		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:
1058	def import_csv(self, _path: str = 'file.csv') -> tuple:
1059		"""
1060        Import transactions from a CSV file.
1061
1062        Parameters:
1063        _path (str): The path to the CSV file. Default is 'file.csv'.
1064
1065        Returns:
1066        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions.
1067
1068        The CSV file should have the following format:
1069        account, desc, value, date
1070        For example:
1071        safe-45, "Some text", 34872, 1988-06-30 00:00:00
1072
1073        The function reads the CSV file, checks for duplicate transactions, and creates or updates the transactions in the system.
1074        """
1075		cache = []
1076		tmp = "tmp"
1077		try:
1078			with open(tmp, "rb") as f:
1079				cache = pickle.load(f)
1080		except:
1081			pass
1082		date_formats = [
1083			"%Y-%m-%d %H:%M:%S",
1084			"%Y-%m-%dT%H:%M:%S",
1085			"%Y-%m-%dT%H%M%S",
1086			"%Y-%m-%d",
1087		]
1088		created, found, bad = 0, 0, {}
1089		with open(_path, newline='', encoding="utf-8") as f:
1090			i = 0
1091			for row in csv.reader(f, delimiter=','):
1092				i += 1
1093				hashed = hash(tuple(row))
1094				if hashed in cache:
1095					found += 1
1096					continue
1097				account = row[0]
1098				desc = row[1]
1099				value = float(row[2])
1100				date = 0
1101				for time_format in date_formats:
1102					try:
1103						date = self.time(datetime.datetime.strptime(row[3], time_format))
1104						break
1105					except:
1106						pass
1107				# TODO: not allowed for negative dates
1108				if date == 0 or value == 0:
1109					bad[i] = row
1110					continue
1111				if value > 0:
1112					self.track(value, desc, account, True, date)
1113				elif value < 0:
1114					self.sub(-value, desc, account, date)
1115				created += 1
1116				cache.append(hashed)
1117		with open(tmp, "wb") as f:
1118				pickle.dump(cache, f)
1119		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 or updates the transactions in the system.

@staticmethod
def DurationFromNanoSeconds(ns: int) -> tuple:
1125	@staticmethod
1126	def DurationFromNanoSeconds(ns: int) -> tuple:
1127		"""REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1128			Convert NanoSeconds to Human Readable Time Format.
1129			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.
1130			Its symbol is μs, sometimes simplified to us when Unicode is not available.
1131			A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1132
1133			INPUT : ms (AKA: MilliSeconds)
1134			OUTPUT: tuple(string TIMELAPSED, string SPOKENTIME) like format.
1135			OUTPUT Variables: TIMELAPSED, SPOKENTIME
1136
1137			Example  Input: DurationFromNanoSeconds(ns)
1138			**"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1139			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')
1140			DurationFromNanoSeconds(1234567890123456789012)
1141		"""
1142		μs, ns      = divmod(ns, 1000)
1143		ms, μs      = divmod(μs, 1000)
1144		s, ms       = divmod(ms, 1000)
1145		m, s        = divmod(s, 60)
1146		h, m        = divmod(m, 60)
1147		d, h        = divmod(h, 24)
1148		y, d        = divmod(d, 365)
1149		c, y        = divmod(y, 100)
1150		n, c        = divmod(c, 10)
1151		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}"
1152		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"
1153		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 generate_random_date(start_date, end_date):
1155	@staticmethod
1156	def generate_random_date(start_date, end_date):
1157		"""
1158		Generate a random date between two given dates.
1159
1160		Parameters:
1161		start_date (datetime.datetime): The start date from which to generate a random date.
1162		end_date (datetime.datetime): The end date until which to generate a random date.
1163
1164		Returns:
1165		datetime.datetime: A random date between the start_date and end_date.
1166		"""
1167		time_between_dates = end_date - start_date
1168		days_between_dates = time_between_dates.days
1169		random_number_of_days = random.randrange(days_between_dates)
1170		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):
1172	@staticmethod
1173	def generate_random_csv_file(_path: str = "data.csv", count: int = 1000):
1174		"""
1175		Generate a random CSV file with specified parameters.
1176
1177		Parameters:
1178		_path (str): The path where the CSV file will be saved. Default is "data.csv".
1179		count (int): The number of rows to generate in the CSV file. Default is 1000.
1180
1181		Returns:
1182		None. The function generates a CSV file at the specified path with the given count of rows.
1183		Each row contains a randomly generated account, description, value, and date.
1184		The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31.
1185		If the row number is not divisible by 13, the value is multiplied by -1.
1186		"""
1187		with open(_path, "w", newline="") as csvfile:
1188			writer = csv.writer(csvfile)
1189			for i in range(count):
1190				account = f"acc-{random.randint(1, 1000)}"
1191				desc = f"Some text {random.randint(1, 1000)}"
1192				value = random.randint(1000, 100000)
1193				date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1194				if not i % 13 == 0:
1195					value *= -1
1196				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.

def test(self, debug: bool = False):
1336	def test(self, debug: bool = False):
1337
1338		try:
1339
1340			assert self._history()
1341
1342			# Not allowed for duplicate transactions in the same account and time
1343
1344			created = ZakatTracker.time()
1345			self.track(100, 'test-1', 'same', True, created)
1346			failed = False
1347			try:
1348				self.track(50, 'test-1', 'same', True, created)
1349			except:
1350				failed = True
1351			assert failed is True
1352
1353			self.reset()
1354
1355			# Always preserve box age during transfer
1356
1357			created = ZakatTracker.time()
1358			series = [
1359				(30, 4),
1360				(60, 3),
1361				(90, 2),
1362			]
1363			case = {
1364				30: {
1365					'series': series,
1366					'rest' : 150,
1367				},
1368				60: {
1369					'series': series,
1370					'rest' : 120,
1371				},
1372				90: {
1373					'series': series,
1374					'rest' : 90,
1375				},
1376				180: {
1377					'series': series,
1378					'rest' : 0,
1379				},
1380				270: {
1381					'series': series,
1382					'rest' : -90,
1383				},
1384				360: {
1385					'series': series,
1386					'rest' : -180,
1387				},
1388			}
1389
1390			selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
1391				
1392			for total in case:
1393				for x in case[total]['series']:
1394					self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
1395
1396				refs = self.transfer(total, 'ages', 'future', 'Zakat Movement')
1397
1398				if debug:
1399					print('refs', refs)
1400
1401				ages_cache_balance = self.balance('ages')
1402				ages_fresh_balance = self.balance('ages', False)
1403				rest = case[total]['rest']
1404				if debug:
1405					print('source', ages_cache_balance, ages_fresh_balance, rest)
1406				assert ages_cache_balance == rest
1407				assert ages_fresh_balance == rest
1408
1409				future_cache_balance = self.balance('future')
1410				future_fresh_balance = self.balance('future', False)
1411				if debug:
1412					print('target', future_cache_balance, future_fresh_balance, total)
1413					print('refs', refs)
1414				assert future_cache_balance == total
1415				assert future_fresh_balance == total
1416
1417				for ref in self._vault['account']['ages']['box']:
1418					ages_capital = self._vault['account']['ages']['box'][ref]['capital']
1419					ages_rest = self._vault['account']['ages']['box'][ref]['rest']
1420					future_capital = 0
1421					if ref in self._vault['account']['future']['box']:
1422						future_capital = self._vault['account']['future']['box'][ref]['capital']
1423					future_rest = 0
1424					if ref in self._vault['account']['future']['box']:
1425						future_rest = self._vault['account']['future']['box'][ref]['rest']
1426					if ages_capital != 0 and future_capital != 0 and future_rest != 0:
1427						if debug:
1428							print('================================================================')
1429							print('ages', ages_capital, ages_rest)
1430							print('future', future_capital, future_rest)
1431						if ages_rest == 0:
1432							assert ages_capital == future_capital
1433						elif ages_rest < 0:
1434							assert -ages_capital == future_capital
1435						elif ages_rest > 0:
1436							assert ages_capital == ages_rest + future_capital
1437				self.reset()
1438				assert len(self._vault['history']) == 0
1439
1440			assert self._history()
1441			assert self._history(False) is False
1442			assert self._history() is False
1443			assert self._history(True)
1444			assert self._history()
1445
1446			self._test_core(True, debug)
1447			self._test_core(False, debug)
1448
1449			transaction = [
1450				(
1451					20, 'wallet', 1, 800, 800, 800, 4, 5,
1452									-85, -85, -85, 6, 7,
1453				),
1454				(
1455					750, 'wallet', 'safe',  50,   50,  50, 4, 6,
1456											750, 750, 750, 1, 1,
1457				),
1458				(
1459					600, 'safe', 'bank', 150, 150, 150, 1, 2,
1460										600, 600, 600, 1, 1,
1461				),
1462			]
1463			for z in transaction:
1464				self.lock()
1465				x = z[1]
1466				y = z[2]
1467				self.transfer(z[0], x, y, 'test-transfer')
1468				assert self.balance(x) == z[3]
1469				xx = self.accounts()[x]
1470				assert xx == z[3]
1471				assert self.balance(x, False) == z[4]
1472				assert xx == z[4]
1473
1474				l = self._vault['account'][x]['log']
1475				s = 0
1476				for i in l:
1477					s += l[i]['value']
1478				if debug:
1479					print('s', s, 'z[5]', z[5])
1480				assert s == z[5]
1481
1482				assert self.box_size(x) == z[6]
1483				assert self.log_size(x) == z[7]
1484
1485				yy = self.accounts()[y]
1486				assert self.balance(y) == z[8]
1487				assert yy == z[8]
1488				assert self.balance(y, False) == z[9]
1489				assert yy == z[9]
1490
1491				l = self._vault['account'][y]['log']
1492				s = 0
1493				for i in l:
1494					s += l[i]['value']
1495				assert s == z[10]
1496
1497				assert self.box_size(y) == z[11]
1498				assert self.log_size(y) == z[12]
1499
1500			if debug:
1501				pp().pprint(self.check(2.17))
1502
1503			assert not self.nolock()
1504			history_count = len(self._vault['history'])
1505			if debug:
1506				print('history-count', history_count)
1507			assert history_count == 11
1508			assert not self.free(ZakatTracker.time())
1509			assert self.free(self.lock())
1510			assert self.nolock()
1511			assert len(self._vault['history']) == 11
1512
1513			# storage
1514
1515			_path = self.path('test.pickle')
1516			if os.path.exists(_path):
1517				os.remove(_path)
1518			self.save()
1519			assert os.path.getsize(_path) > 0
1520			self.reset()
1521			assert self.recall(False, debug) is False
1522			self.load()
1523			assert self._vault['account'] is not None
1524
1525			# recall
1526
1527			assert self.nolock()
1528			assert len(self._vault['history']) == 11
1529			assert self.recall(False, debug) is True
1530			assert len(self._vault['history']) == 10
1531			assert self.recall(False, debug) is True
1532			assert len(self._vault['history']) == 9
1533
1534			# csv
1535			
1536			_path = "test.csv"
1537			count = 1000
1538			if os.path.exists(_path):
1539				os.remove(_path)
1540			self.generate_random_csv_file(_path, count)
1541			assert os.path.getsize(_path) > 0
1542			tmp = "tmp"
1543			if os.path.exists(tmp):
1544				os.remove(tmp)
1545			(created, found, bad) = self.import_csv(_path)
1546			bad_count = len(bad)
1547			if debug:
1548				print(f"csv-imported: ({created}, {found}, {bad_count})")
1549			tmp_size = os.path.getsize(tmp)
1550			assert tmp_size > 0
1551			assert created + found + bad_count == count
1552			assert created == count
1553			assert bad_count == 0
1554			(created_2, found_2, bad_2) = self.import_csv(_path)
1555			bad_2_count = len(bad_2)
1556			if debug:
1557				print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
1558				print(bad)
1559			assert tmp_size == os.path.getsize(tmp)
1560			assert created_2 + found_2 + bad_2_count == count
1561			assert created == found_2
1562			assert bad_count == bad_2_count
1563			assert found_2 == count
1564			assert bad_2_count == 0
1565			assert created_2 == 0
1566
1567			# payment parts
1568
1569			positive_parts = self.build_payment_parts(100, positive_only=True)
1570			assert self.check_payment_parts(positive_parts) != 0
1571			assert self.check_payment_parts(positive_parts) != 0
1572			all_parts = self.build_payment_parts(300, positive_only= False)
1573			assert self.check_payment_parts(all_parts) != 0
1574			assert self.check_payment_parts(all_parts) != 0
1575			if debug:
1576				pp().pprint(positive_parts)
1577				pp().pprint(all_parts)
1578			# dynamic discount
1579			suite = []
1580			count = 3
1581			for exceed in [False, True]:
1582				case = []
1583				for parts in [positive_parts, all_parts]:
1584					part = parts.copy()
1585					demand = part['demand']
1586					if debug:
1587						print(demand, part['total'])
1588					i = 0
1589					z = demand / count
1590					cp = {
1591						'account': {},
1592						'demand': demand,
1593						'exceed': exceed,
1594						'total': part['total'],
1595					}
1596					for x, y in part['account'].items():
1597						if exceed and z <= demand:
1598							i += 1
1599							y['part'] = z
1600							if debug:
1601								print(exceed, y)
1602							cp['account'][x] = y
1603							case.append(y)
1604						elif not exceed and y['balance'] >= z:
1605								i += 1
1606								y['part'] = z
1607								if debug:
1608									print(exceed, y)
1609								cp['account'][x] = y
1610								case.append(y)
1611						if i >= count:
1612								break
1613					if len(cp['account'][x]) > 0:
1614						suite.append(cp)
1615			if debug:
1616				print('suite', len(suite))
1617			for case in suite:
1618				if debug:
1619					print(case)
1620				result = self.check_payment_parts(case)
1621				if debug:
1622					print('check_payment_parts', result)
1623				assert result == 0
1624				
1625			report = self.check(2.17, None, debug)
1626			(valid, brief, plan) = report
1627			if debug:
1628				print('valid', valid)
1629			assert self.zakat(report, parts=suite, debug=debug)
1630
1631			assert self.export_json("1000-transactions-test.json")
1632			assert self.save("1000-transactions-test.pickle")
1633
1634			# check & zakat
1635
1636			cases = [
1637				(1000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [
1638					{'safe': {0: {'below_nisab': 25}}},
1639				], False),
1640				(2000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [
1641					{'safe': {0: {'count': 1, 'total': 50}}},
1642				], True),
1643				(10000, 'cave', ZakatTracker.time()-(ZakatTracker.TimeCycle()*3), [
1644					{'cave': {0: {'count': 3, 'total': 731.40625}}},
1645				], True),
1646			]
1647			for case in cases:
1648				if debug:
1649					print("############# check #############")
1650				self.reset()
1651				self.track(case[0], 'test-check', case[1], True, case[2])
1652
1653				assert self.nolock()
1654				assert len(self._vault['history']) == 1
1655				assert self.lock()
1656				assert not self.nolock()
1657				report = self.check(2.17, None, debug)
1658				(valid, brief, plan) = report
1659				assert valid == case[4]
1660				if debug:
1661					print(brief)
1662				assert case[0] == brief[0]
1663				assert case[0] == brief[1]
1664
1665				if debug:
1666					pp().pprint(plan)
1667
1668				for x in plan:
1669					assert case[1] == x
1670					if 'total' in case[3][0][x][0].keys():
1671						assert case[3][0][x][0]['total'] == brief[2]
1672						assert plan[x][0]['total'] == case[3][0][x][0]['total']
1673						assert plan[x][0]['count'] == case[3][0][x][0]['count']
1674					else:
1675						assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
1676				if debug:
1677					pp().pprint(report)
1678				result = self.zakat(report, debug=debug)
1679				if debug:
1680					print('zakat-result', result, case[4])
1681				assert result == case[4]
1682				report = self.check(2.17, None, debug)
1683				(valid, brief, plan) = report
1684				assert valid is False
1685
1686			assert len(self._vault['history']) == 2
1687			assert not self.nolock()
1688			assert self.recall(False, debug) is False
1689			self.free(self.lock())
1690			assert self.nolock()
1691			assert len(self._vault['history']) == 2
1692			assert self.recall(False, debug) is True
1693			assert len(self._vault['history']) == 1
1694
1695			assert self.nolock()
1696			assert len(self._vault['history']) == 1
1697
1698			assert self.recall(False, debug) is True
1699			assert len(self._vault['history']) == 0
1700
1701			assert len(self._vault['account']) == 0
1702			assert len(self._vault['history']) == 0
1703			assert len(self._vault['report']) == 0
1704			assert self.nolock()
1705		except:
1706			pp().pprint(self._vault)
1707			raise
class Action(enum.Enum):
66class Action(Enum):
67		CREATE = auto()
68		TRACK = auto()
69		LOG = auto()
70		SUB = auto()
71		ADD_FILE = auto()
72		REMOVE_FILE = auto()
73		REPORT = auto()
74		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>
REPORT = <Action.REPORT: 7>
ZAKAT = <Action.ZAKAT: 8>
class JSONEncoder(json.encoder.JSONEncoder):
76class JSONEncoder(json.JSONEncoder):
77	def default(self, obj):
78		if isinstance(obj, Action) or isinstance(obj, MathOperation):
79			return obj.name  # Serialize as the enum member's name
80		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):
77	def default(self, obj):
78		if isinstance(obj, Action) or isinstance(obj, MathOperation):
79			return obj.name  # Serialize as the enum member's name
80		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):
82class MathOperation(Enum):
83	ADDITION = auto()
84	EQUAL = auto()
85	SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>