zakat.zakat_tracker
xxx

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

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

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

The ZakatTracker class allows users to record financial transactions, and calculate Zakat due based on the Nisab (the minimum threshold for Zakat) and Haul (after completing one year since every transaction received in the same account). We use the current silver price and manage account balances. It supports importing transactions from CSV files, exporting data to JSON format, and saving/loading the tracker state.

Key Features:

  • Tracking of positive and negative transactions
  • Calculation of Zakat based on Nisab, Haul and silver price
  • Import of transactions from CSV files
  • Export of data to JSON format
  • Persistence of tracker state using pickle files
  • History tracking (optional)

The module also includes a few helper functions and classes:

  • JSONEncoder: A custom JSON encoder for serializing enum values.
  • Action (Enum): An enumeration representing different actions in the tracker.
  • MathOperation (Enum): An enumeration representing mathematical operations in the tracker.

The ZakatTracker class is designed to be flexible and extensible, allowing users to customize it to their specific needs.

Example usage:

from zakat_tracker import ZakatTracker

tracker = ZakatTracker()
tracker.track(10000, "Initial deposit")
tracker.sub(500, "Expense")
report = tracker.check(2.5)  # Assuming silver price is 2.5 per gram
tracker.zakat(report)

In this file docstring:

  1. We begin with a verse from the Quran and a motivational quote.
  2. We provide a brief description of the module's purpose.
  3. We highlight the key features of the ZakatTracker class.
  4. We mention the additional helper functions and classes.
  5. We provide a simple usage example to illustrate how to use the ZakatTracker class.

Feel free to suggest any modifications or additions to tailor this file docstring to your preferences.

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

A class for tracking and calculating Zakat.

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

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

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

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

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

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

_vault (dict):
    - account (dict):
        - {account_number} (dict):
            - balance (int): The current balance of the account.
            - box (dict): A dictionary storing transaction details.
                - {timestamp} (dict):
                    - capital (int): The initial amount of the transaction.
                    - count (int): The number of times Zakat has been calculated for this transaction.
                    - last (int): The timestamp of the last Zakat calculation.
                    - rest (int): The remaining amount after Zakat deductions and withdrawal.
                    - total (int): The total Zakat deducted from this transaction.
            - count (int): The total number of transactions for the account.
            - log (dict): A dictionary storing transaction logs.
                - {timestamp} (dict):
                    - value (int): The transaction amount (positive or negative).
                    - desc (str): The description of the transaction.
                    - ref (int): The box reference (positive or None).
                    - file (dict): A dictionary storing file references associated with the transaction.
            - hide (bool): Indicates whether the account is hidden or not.
            - zakatable (bool): Indicates whether the account is subject to Zakat.
    - exchange (dict):
        - account (dict):
            - {timestamps} (dict):
                - rate (float): Exchange rate when compared to local currency.
                - description (str): The description of the exchange rate.
    - history (dict):
        - {timestamp} (list): A list of dictionaries storing the history of actions performed.
            - {action_dict} (dict):
                - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
                - account (str): The account number associated with the action.
                - ref (int): The reference number of the transaction.
                - file (int): The reference number of the file (if applicable).
                - key (str): The key associated with the action (e.g., 'rest', 'total').
                - value (int): The value associated with the action.
                - math (MathOperation): The mathematical operation performed (if applicable).
    - lock (int or None): The timestamp indicating the current lock status (None if not locked).
    - report (dict):
        - {timestamp} (tuple): A tuple storing Zakat report details.
ZakatTracker(db_path: str = 'zakat.pickle', history_mode: bool = True)
238    def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True):
239        """
240        Initialize ZakatTracker with database path and history mode.
241
242        Parameters:
243        db_path (str): The path to the database file. Default is "zakat.pickle".
244        history_mode (bool): The mode for tracking history. Default is True.
245
246        Returns:
247        None
248        """
249        self._vault_path = None
250        self._vault = None
251        self.reset()
252        self._history(history_mode)
253        self.path(db_path)
254        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

@staticmethod
def Version():
172    @staticmethod
173    def Version():
174        """
175        Returns the current version of the software.
176
177        This function returns a string representing the current version of the software,
178        including major, minor, and patch version numbers in the format "X.Y.Z".
179
180        Returns:
181        str: The current version of the software.
182        """
183        return '0.2.70'

Returns the current version of the software.

This function returns a string representing the current version of the software, including major, minor, and patch version numbers in the format "X.Y.Z".

Returns: str: The current version of the software.

@staticmethod
def ZakatCut(x: float) -> float:
185    @staticmethod
186    def ZakatCut(x: float) -> float:
187        """
188        Calculates the Zakat amount due on an asset.
189
190        This function calculates the zakat amount due on a given asset value over one lunar year.
191        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
192        that exceeds a certain threshold (Nisab).
193
194        Parameters:
195        x: The total value of the asset on which Zakat is to be calculated.
196
197        Returns:
198        The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
199        """
200        return 0.025 * x  # Zakat Cut in one Lunar Year

Calculates the Zakat amount due on an asset.

This function calculates the zakat amount due on a given asset value over one lunar year. Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth that exceeds a certain threshold (Nisab).

Parameters: x: The total value of the asset on which Zakat is to be calculated.

Returns: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.

@staticmethod
def TimeCycle(days: int = 355) -> int:
202    @staticmethod
203    def TimeCycle(days: int = 355) -> int:
204        """
205        Calculates the approximate duration of a lunar year in nanoseconds.
206
207        This function calculates the approximate duration of a lunar year based on the given number of days.
208        It converts the given number of days into nanoseconds for use in high-precision timing applications.
209
210        Parameters:
211        days: The number of days in a lunar year. Defaults to 355,
212              which is an approximation of the average length of a lunar year.
213
214        Returns:
215        The approximate duration of a lunar year in nanoseconds.
216        """
217        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds

Calculates the approximate duration of a lunar year in nanoseconds.

This function calculates the approximate duration of a lunar year based on the given number of days. It converts the given number of days into nanoseconds for use in high-precision timing applications.

Parameters: days: The number of days in a lunar year. Defaults to 355, which is an approximation of the average length of a lunar year.

Returns: The approximate duration of a lunar year in nanoseconds.

@staticmethod
def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
219    @staticmethod
220    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
221        """
222        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
223
224        This function calculates the Nisab value, which is the minimum threshold of wealth,
225        that makes an individual liable for paying Zakat.
226        The Nisab value is determined by the equivalent value of a specific amount
227        of gold or silver (currently 595 grams in silver) in the local currency.
228
229        Parameters:
230        - gram_price (float): The price per gram of Nisab.
231        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
232
233        Returns:
234        - float: The total value of Nisab based on the given price per gram.
235        """
236        return gram_price * gram_quantity

Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.

This function calculates the Nisab value, which is the minimum threshold of wealth, that makes an individual liable for paying Zakat. The Nisab value is determined by the equivalent value of a specific amount of gold or silver (currently 595 grams in silver) in the local currency.

Parameters:

  • gram_price (float): The price per gram of Nisab.
  • gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.

Returns:

  • float: The total value of Nisab based on the given price per gram.
def path(self, path: str = None) -> str:
256    def path(self, path: str = None) -> str:
257        """
258        Set or get the database path.
259
260        Parameters:
261        path (str): The path to the database file. If not provided, it returns the current path.
262
263        Returns:
264        str: The current database path.
265        """
266        if path is not None:
267            self._vault_path = path
268        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:
284    def reset(self) -> None:
285        """
286        Reset the internal data structure to its initial state.
287
288        Parameters:
289        None
290
291        Returns:
292        None
293        """
294        self._vault = {
295            'account': {},
296            'exchange': {},
297            'history': {},
298            'lock': None,
299            'report': {},
300        }

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:
302    @staticmethod
303    def time(now: datetime = None) -> int:
304        """
305        Generates a timestamp based on the provided datetime object or the current datetime.
306
307        Parameters:
308        now (datetime, optional): The datetime object to generate the timestamp from.
309        If not provided, the current datetime is used.
310
311        Returns:
312        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
313            before 1970 will return in negative until 1000AD.
314        """
315        if now is None:
316            now = datetime.datetime.now()
317        ordinal_day = now.toordinal()
318        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
319        return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)

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

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

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

@staticmethod
def time_to_datetime( ordinal_ns: int) -> <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'>:
321    @staticmethod
322    def time_to_datetime(ordinal_ns: int) -> datetime:
323        """
324        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
325
326        Parameters:
327        ordinal_ns (int): The ordinal number of days since 1000-01-01.
328
329        Returns:
330        datetime: The corresponding datetime object.
331        """
332        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
333        ns_in_day = ordinal_ns % 86_400_000_000_000
334        d = datetime.datetime.fromordinal(ordinal_day)
335        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
336        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:
374    def nolock(self) -> bool:
375        """
376        Check if the vault lock is currently not set.
377
378        Returns:
379        bool: True if the vault lock is not set, False otherwise.
380        """
381        return self._vault['lock'] is None

Check if the vault lock is currently not set.

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

def lock(self) -> int:
383    def lock(self) -> int:
384        """
385        Acquires a lock on the ZakatTracker instance.
386
387        Returns:
388        int: The lock ID. This ID can be used to release the lock later.
389        """
390        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:
392    def box(self) -> dict:
393        """
394        Returns a copy of the internal vault dictionary.
395
396        This method is used to retrieve the current state of the ZakatTracker object.
397        It provides a snapshot of the internal data structure, allowing for further
398        processing or analysis.
399
400        Returns:
401        dict: A copy of the internal vault dictionary.
402        """
403        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: dict: A copy of the internal vault dictionary.

def steps(self) -> dict:
405    def steps(self) -> dict:
406        """
407        Returns a copy of the history of steps taken in the ZakatTracker.
408
409        The history is a dictionary where each key is a unique identifier for a step,
410        and the corresponding value is a dictionary containing information about the step.
411
412        Returns:
413        dict: A copy of the history of steps taken in the ZakatTracker.
414        """
415        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: dict: A copy of the history of steps taken in the ZakatTracker.

def free(self, lock: int, auto_save: bool = True) -> bool:
417    def free(self, lock: int, auto_save: bool = True) -> bool:
418        """
419        Releases the lock on the database.
420
421        Parameters:
422        lock (int): The lock ID to be released.
423        auto_save (bool): Whether to automatically save the database after releasing the lock.
424
425        Returns:
426        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
427        """
428        if lock == self._vault['lock']:
429            self._vault['lock'] = None
430            if auto_save:
431                return self.save(self.path())
432            return True
433        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:
435    def account_exists(self, account) -> bool:
436        """
437        Check if the given account exists in the vault.
438
439        Parameters:
440        account (str): The account number to check.
441
442        Returns:
443        bool: True if the account exists, False otherwise.
444        """
445        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:
447    def box_size(self, account) -> int:
448        """
449        Calculate the size of the box for a specific account.
450
451        Parameters:
452        account (str): The account number for which the box size needs to be calculated.
453
454        Returns:
455        int: The size of the box for the given account. If the account does not exist, -1 is returned.
456        """
457        if self.account_exists(account):
458            return len(self._vault['account'][account]['box'])
459        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:
461    def log_size(self, account) -> int:
462        """
463        Get the size of the log for a specific account.
464
465        Parameters:
466        account (str): The account number for which the log size needs to be calculated.
467
468        Returns:
469        int: The size of the log for the given account. If the account does not exist, -1 is returned.
470        """
471        if self.account_exists(account):
472            return len(self._vault['account'][account]['log'])
473        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:
475    def recall(self, dry=True, debug=False) -> bool:
476        """
477        Revert the last operation.
478
479        Parameters:
480        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
481        debug (bool): If True, the function will print debug information. Default is False.
482
483        Returns:
484        bool: True if the operation was successful, False otherwise.
485        """
486        if not self.nolock() or len(self._vault['history']) == 0:
487            return False
488        if len(self._vault['history']) <= 0:
489            return False
490        ref = sorted(self._vault['history'].keys())[-1]
491        if debug:
492            print('recall', ref)
493        memory = self._vault['history'][ref]
494        if debug:
495            print(type(memory), 'memory', memory)
496
497        limit = len(memory) + 1
498        sub_positive_log_negative = 0
499        for i in range(-1, -limit, -1):
500            x = memory[i]
501            if debug:
502                print(type(x), x)
503            match x['action']:
504                case Action.CREATE:
505                    if x['account'] is not None:
506                        if self.account_exists(x['account']):
507                            if debug:
508                                print('account', self._vault['account'][x['account']])
509                            assert len(self._vault['account'][x['account']]['box']) == 0
510                            assert self._vault['account'][x['account']]['balance'] == 0
511                            assert self._vault['account'][x['account']]['count'] == 0
512                            if dry:
513                                continue
514                            del self._vault['account'][x['account']]
515
516                case Action.TRACK:
517                    if x['account'] is not None:
518                        if self.account_exists(x['account']):
519                            if dry:
520                                continue
521                            self._vault['account'][x['account']]['balance'] -= x['value']
522                            self._vault['account'][x['account']]['count'] -= 1
523                            del self._vault['account'][x['account']]['box'][x['ref']]
524
525                case Action.LOG:
526                    if x['account'] is not None:
527                        if self.account_exists(x['account']):
528                            if x['ref'] in self._vault['account'][x['account']]['log']:
529                                if dry:
530                                    continue
531                                if sub_positive_log_negative == -x['value']:
532                                    self._vault['account'][x['account']]['count'] -= 1
533                                    sub_positive_log_negative = 0
534                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
535                                if not box_ref is None:
536                                    assert self.box_exists(x['account'], box_ref)
537                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
538                                    assert box_value < 0
539                                    self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
540                                    self._vault['account'][x['account']]['balance'] += -box_value
541                                    self._vault['account'][x['account']]['count'] -= 1
542                                del self._vault['account'][x['account']]['log'][x['ref']]
543
544                case Action.SUB:
545                    if x['account'] is not None:
546                        if self.account_exists(x['account']):
547                            if x['ref'] in self._vault['account'][x['account']]['box']:
548                                if dry:
549                                    continue
550                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
551                                self._vault['account'][x['account']]['balance'] += x['value']
552                                sub_positive_log_negative = x['value']
553
554                case Action.ADD_FILE:
555                    if x['account'] is not None:
556                        if self.account_exists(x['account']):
557                            if x['ref'] in self._vault['account'][x['account']]['log']:
558                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
559                                    if dry:
560                                        continue
561                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
562
563                case Action.REMOVE_FILE:
564                    if x['account'] is not None:
565                        if self.account_exists(x['account']):
566                            if x['ref'] in self._vault['account'][x['account']]['log']:
567                                if dry:
568                                    continue
569                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
570
571                case Action.BOX_TRANSFER:
572                    if x['account'] is not None:
573                        if self.account_exists(x['account']):
574                            if x['ref'] in self._vault['account'][x['account']]['box']:
575                                if dry:
576                                    continue
577                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
578
579                case Action.EXCHANGE:
580                    if x['account'] is not None:
581                        if x['account'] in self._vault['exchange']:
582                            if x['ref'] in self._vault['exchange'][x['account']]:
583                                if dry:
584                                    continue
585                                del self._vault['exchange'][x['account']][x['ref']]
586
587                case Action.REPORT:
588                    if x['ref'] in self._vault['report']:
589                        if dry:
590                            continue
591                        del self._vault['report'][x['ref']]
592
593                case Action.ZAKAT:
594                    if x['account'] is not None:
595                        if self.account_exists(x['account']):
596                            if x['ref'] in self._vault['account'][x['account']]['box']:
597                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
598                                    if dry:
599                                        continue
600                                    match x['math']:
601                                        case MathOperation.ADDITION:
602                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
603                                                'value']
604                                        case MathOperation.EQUAL:
605                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
606                                        case MathOperation.SUBTRACTION:
607                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
608                                                'value']
609
610        if not dry:
611            del self._vault['history'][ref]
612        return True

Revert the last operation.

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

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

def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
614    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
615        """
616        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
617
618        Parameters:
619        account (str): The account number for which to check the existence of the reference.
620        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
621        ref (int): The reference (transaction) number to check for existence.
622
623        Returns:
624        bool: True if the reference exists for the given account and reference type, False otherwise.
625        """
626        if account in self._vault['account']:
627            return ref in self._vault['account'][account][ref_type]
628        return False

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

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

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

def box_exists(self, account: str, ref: int) -> bool:
630    def box_exists(self, account: str, ref: int) -> bool:
631        """
632        Check if a specific box (transaction) exists in the vault for a given account and reference.
633
634        Parameters:
635        - account (str): The account number for which to check the existence of the box.
636        - ref (int): The reference (transaction) number to check for existence.
637
638        Returns:
639        - bool: True if the box exists for the given account and reference, False otherwise.
640        """
641        return self.ref_exists(account, 'box', ref)

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

Parameters:

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

Returns:

  • bool: True if the box exists for the given account and reference, False otherwise.
def track( self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
643    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
644              debug: bool = False) -> int:
645        """
646        This function tracks a transaction for a specific account.
647
648        Parameters:
649        value (float): The value of the transaction. Default is 0.
650        desc (str): The description of the transaction. Default is an empty string.
651        account (str): The account for which the transaction is being tracked. Default is '1'.
652        logging (bool): Whether to log the transaction. Default is True.
653        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
654        debug (bool): Whether to print debug information. Default is False.
655
656        Returns:
657        int: The timestamp of the transaction.
658
659        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.
660
661        Raises:
662        ValueError: The log transaction happened again in the same nanosecond time.
663        ValueError: The box transaction happened again in the same nanosecond time.
664        """
665        if debug:
666            print('track', f'debug={debug}')
667        if created is None:
668            created = self.time()
669        no_lock = self.nolock()
670        self.lock()
671        if not self.account_exists(account):
672            if debug:
673                print(f"account {account} created")
674            self._vault['account'][account] = {
675                'balance': 0,
676                'box': {},
677                'count': 0,
678                'log': {},
679                'hide': False,
680                'zakatable': True,
681            }
682            self._step(Action.CREATE, account)
683        if value == 0:
684            if no_lock:
685                self.free(self.lock())
686            return 0
687        if logging:
688            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
689        if debug:
690            print('create-box', created)
691        if self.box_exists(account, created):
692            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
693        if debug:
694            print('created-box', created)
695        self._vault['account'][account]['box'][created] = {
696            'capital': value,
697            'count': 0,
698            'last': 0,
699            'rest': value,
700            'total': 0,
701        }
702        self._step(Action.TRACK, account, ref=created, value=value)
703        if no_lock:
704            self.free(self.lock())
705        return created

This function tracks a transaction for a specific account.

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

Returns: int: The timestamp of the transaction.

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

Raises: ValueError: The log transaction happened again in the same nanosecond time. ValueError: The box transaction happened again in the same nanosecond time.

def log_exists(self, account: str, ref: int) -> bool:
707    def log_exists(self, account: str, ref: int) -> bool:
708        """
709        Checks if a specific transaction log entry exists for a given account.
710
711        Parameters:
712        account (str): The account number associated with the transaction log.
713        ref (int): The reference to the transaction log entry.
714
715        Returns:
716        bool: True if the transaction log entry exists, False otherwise.
717        """
718        return self.ref_exists(account, 'log', ref)

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

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

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

def exchange( self, account, created: int = None, rate: float = None, description: str = None, debug: bool = False) -> dict:
763    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
764                 debug: bool = False) -> dict:
765        """
766        This method is used to record or retrieve exchange rates for a specific account.
767
768        Parameters:
769        - account (str): The account number for which the exchange rate is being recorded or retrieved.
770        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
771        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
772        - description (str): A description of the exchange rate.
773
774        Returns:
775        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
776        it returns a dictionary with default values for the rate and description.
777        """
778        if debug:
779            print('exchange', f'debug={debug}')
780        if created is None:
781            created = self.time()
782        no_lock = self.nolock()
783        self.lock()
784        if rate is not None:
785            if rate <= 0:
786                return dict()
787            if account not in self._vault['exchange']:
788                self._vault['exchange'][account] = {}
789            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
790                return {"time": created, "rate": 1, "description": None}
791            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
792            self._step(Action.EXCHANGE, account, ref=created, value=rate)
793            if no_lock:
794                self.free(self.lock())
795            if debug:
796                print("exchange-created-1",
797                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
798
799        if account in self._vault['exchange']:
800            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
801            if valid_rates:
802                latest_rate = max(valid_rates, key=lambda x: x[0])
803                if debug:
804                    print("exchange-read-1",
805                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
806                          'latest_rate', latest_rate)
807                result = latest_rate[1]
808                result['time'] = latest_rate[0]
809                return result  # إرجاع قاموس يحتوي على المعدل والوصف
810        if debug:
811            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
812        return {"time": created, "rate": 1, "description": None}  # إرجاع القيمة الافتراضية مع وصف فارغ

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

Parameters:

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

Returns:

  • dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, it returns a dictionary with default values for the rate and description.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
814    @staticmethod
815    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
816        """
817        This function calculates the exchanged amount of a currency.
818
819        Args:
820            x (float): The original amount of the currency.
821            x_rate (float): The exchange rate of the original currency.
822            y_rate (float): The exchange rate of the target currency.
823
824        Returns:
825            float: The exchanged amount of the target currency.
826        """
827        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

Args: x (float): The original amount of the currency. x_rate (float): The exchange rate of the original currency. y_rate (float): The exchange rate of the target currency.

Returns: float: The exchanged amount of the target currency.

def exchanges(self) -> dict:
829    def exchanges(self) -> dict:
830        """
831        Retrieve the recorded exchange rates for all accounts.
832
833        Parameters:
834        None
835
836        Returns:
837        dict: A dictionary containing all recorded exchange rates.
838        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
839        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
840        """
841        return self._vault['exchange'].copy()

Retrieve the recorded exchange rates for all accounts.

Parameters: None

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

def accounts(self) -> dict:
843    def accounts(self) -> dict:
844        """
845        Returns a dictionary containing account numbers as keys and their respective balances as values.
846
847        Parameters:
848        None
849
850        Returns:
851        dict: A dictionary where keys are account numbers and values are their respective balances.
852        """
853        result = {}
854        for i in self._vault['account']:
855            result[i] = self._vault['account'][i]['balance']
856        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:
858    def boxes(self, account) -> dict:
859        """
860        Retrieve the boxes (transactions) associated with a specific account.
861
862        Parameters:
863        account (str): The account number for which to retrieve the boxes.
864
865        Returns:
866        dict: A dictionary containing the boxes associated with the given account.
867        If the account does not exist, an empty dictionary is returned.
868        """
869        if self.account_exists(account):
870            return self._vault['account'][account]['box']
871        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:
873    def logs(self, account) -> dict:
874        """
875        Retrieve the logs (transactions) associated with a specific account.
876
877        Parameters:
878        account (str): The account number for which to retrieve the logs.
879
880        Returns:
881        dict: A dictionary containing the logs associated with the given account.
882        If the account does not exist, an empty dictionary is returned.
883        """
884        if self.account_exists(account):
885            return self._vault['account'][account]['log']
886        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:
888    def add_file(self, account: str, ref: int, path: str) -> int:
889        """
890        Adds a file reference to a specific transaction log entry in the vault.
891
892        Parameters:
893        account (str): The account number associated with the transaction log.
894        ref (int): The reference to the transaction log entry.
895        path (str): The path of the file to be added.
896
897        Returns:
898        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
899        """
900        if self.account_exists(account):
901            if ref in self._vault['account'][account]['log']:
902                file_ref = self.time()
903                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
904                no_lock = self.nolock()
905                self.lock()
906                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
907                if no_lock:
908                    self.free(self.lock())
909                return file_ref
910        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:
912    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
913        """
914        Removes a file reference from a specific transaction log entry in the vault.
915
916        Parameters:
917        account (str): The account number associated with the transaction log.
918        ref (int): The reference to the transaction log entry.
919        file_ref (int): The reference of the file to be removed.
920
921        Returns:
922        bool: True if the file reference is successfully removed, False otherwise.
923        """
924        if self.account_exists(account):
925            if ref in self._vault['account'][account]['log']:
926                if file_ref in self._vault['account'][account]['log'][ref]['file']:
927                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
928                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
929                    no_lock = self.nolock()
930                    self.lock()
931                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
932                    if no_lock:
933                        self.free(self.lock())
934                    return True
935        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:
937    def balance(self, account: str = 1, cached: bool = True) -> int:
938        """
939        Calculate and return the balance of a specific account.
940
941        Parameters:
942        account (str): The account number. Default is '1'.
943        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
944
945        Returns:
946        int: The balance of the account.
947
948        Note:
949        If cached is True, the function returns the cached balance.
950        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
951        """
952        if cached:
953            return self._vault['account'][account]['balance']
954        x = 0
955        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 hide(self, account, status: bool = None) -> bool:
957    def hide(self, account, status: bool = None) -> bool:
958        """
959        Check or set the hide status of a specific account.
960
961        Parameters:
962        account (str): The account number.
963        status (bool, optional): The new hide status. If not provided, the function will return the current status.
964
965        Returns:
966        bool: The current or updated hide status of the account.
967
968        Raises:
969        None
970
971        Example:
972        >>> tracker = ZakatTracker()
973        >>> ref = tracker.track(51, 'desc', 'account1')
974        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
975        False
976        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
977        True
978        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
979        True
980        >>> tracker.hide('account1', False)
981        False
982        """
983        if self.account_exists(account):
984            if status is None:
985                return self._vault['account'][account]['hide']
986            self._vault['account'][account]['hide'] = status
987            return status
988        return False

Check or set the hide status of a specific account.

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

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

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.hide('account1')  # Set the hide status of 'account1' to True
False
>>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
True
>>> tracker.hide('account1')  # Get the hide status of 'account1' by default
True
>>> tracker.hide('account1', False)
False
def zakatable(self, account, status: bool = None) -> bool:
 990    def zakatable(self, account, status: bool = None) -> bool:
 991        """
 992        Check or set the zakatable status of a specific account.
 993
 994        Parameters:
 995        account (str): The account number.
 996        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
 997
 998        Returns:
 999        bool: The current or updated zakatable status of the account.
1000
1001        Raises:
1002        None
1003
1004        Example:
1005        >>> tracker = ZakatTracker()
1006        >>> ref = tracker.track(51, 'desc', 'account1')
1007        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1008        True
1009        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1010        True
1011        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1012        True
1013        >>> tracker.zakatable('account1', False)
1014        False
1015        """
1016        if self.account_exists(account):
1017            if status is None:
1018                return self._vault['account'][account]['zakatable']
1019            self._vault['account'][account]['zakatable'] = status
1020            return status
1021        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()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
True
>>> tracker.zakatable('account1', False)
False
def sub( self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1023    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1024        """
1025        Subtracts a specified value from an account's balance.
1026
1027        Parameters:
1028        x (float): The amount to be subtracted.
1029        desc (str): A description for the transaction. Defaults to an empty string.
1030        account (str): The account from which the value will be subtracted. Defaults to '1'.
1031        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1032        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1033
1034        Returns:
1035        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1036
1037        If the amount to subtract is greater than the account's balance,
1038        the remaining amount will be transferred to a new transaction with a negative value.
1039
1040        Raises:
1041        ValueError: The box transaction happened again in the same nanosecond time.
1042        ValueError: The log transaction happened again in the same nanosecond time.
1043        """
1044        if debug:
1045            print('sub', f'debug={debug}')
1046        if x < 0:
1047            return tuple()
1048        if x == 0:
1049            ref = self.track(x, '', account)
1050            return ref, ref
1051        if created is None:
1052            created = self.time()
1053        no_lock = self.nolock()
1054        self.lock()
1055        self.track(0, '', account)
1056        self._log(value=-x, desc=desc, account=account, created=created, ref=None, debug=debug)
1057        ids = sorted(self._vault['account'][account]['box'].keys())
1058        limit = len(ids) + 1
1059        target = x
1060        if debug:
1061            print('ids', ids)
1062        ages = []
1063        for i in range(-1, -limit, -1):
1064            if target == 0:
1065                break
1066            j = ids[i]
1067            if debug:
1068                print('i', i, 'j', j)
1069            rest = self._vault['account'][account]['box'][j]['rest']
1070            if rest >= target:
1071                self._vault['account'][account]['box'][j]['rest'] -= target
1072                self._step(Action.SUB, account, ref=j, value=target)
1073                ages.append((j, target))
1074                target = 0
1075                break
1076            elif target > rest > 0:
1077                chunk = rest
1078                target -= chunk
1079                self._step(Action.SUB, account, ref=j, value=chunk)
1080                ages.append((j, chunk))
1081                self._vault['account'][account]['box'][j]['rest'] = 0
1082        if target > 0:
1083            self.track(-target, desc, account, False, created)
1084            ages.append((created, target))
1085        if no_lock:
1086            self.free(self.lock())
1087        return created, ages

Subtracts a specified value from an account's balance.

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

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

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

Raises: ValueError: The box transaction happened again in the same nanosecond time. ValueError: The log transaction happened again in the same nanosecond time.

def transfer( self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
1089    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
1090                 debug: bool = False) -> list[int]:
1091        """
1092        Transfers a specified value from one account to another.
1093
1094        Parameters:
1095        amount (int): The amount to be transferred.
1096        from_account (str): The account from which the value will be transferred.
1097        to_account (str): The account to which the value will be transferred.
1098        desc (str, optional): A description for the transaction. Defaults to an empty string.
1099        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1100        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1101
1102        Returns:
1103        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1104
1105        Raises:
1106        ValueError: Transfer to the same account is forbidden.
1107        ValueError: The box transaction happened again in the same nanosecond time.
1108        ValueError: The log transaction happened again in the same nanosecond time.
1109        """
1110        if debug:
1111            print('transfer', f'debug={debug}')
1112        if from_account == to_account:
1113            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1114        if amount <= 0:
1115            return []
1116        if created is None:
1117            created = self.time()
1118        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
1119        times = []
1120        source_exchange = self.exchange(from_account, created)
1121        target_exchange = self.exchange(to_account, created)
1122
1123        if debug:
1124            print('ages', ages)
1125
1126        for age, value in ages:
1127            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
1128            # Perform the transfer
1129            if self.box_exists(to_account, age):
1130                if debug:
1131                    print('box_exists', age)
1132                capital = self._vault['account'][to_account]['box'][age]['capital']
1133                rest = self._vault['account'][to_account]['box'][age]['rest']
1134                if debug:
1135                    print(
1136                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1137                selected_age = age
1138                if rest + target_amount > capital:
1139                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1140                    selected_age = ZakatTracker.time()
1141                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1142                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1143                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1144                              created=None, ref=None, debug=debug)
1145                times.append((age, y))
1146                continue
1147            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1148            if debug:
1149                print(
1150                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1151            times.append(y)
1152        return times

Transfers a specified value from one account to another.

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

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

Raises: ValueError: Transfer to the same account is forbidden. ValueError: The box transaction happened again in the same nanosecond time. ValueError: The log transaction happened again in the same nanosecond time.

def check( self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
1154    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1155              cycle: float = None) -> tuple:
1156        """
1157        Check the eligibility for Zakat based on the given parameters.
1158
1159        Parameters:
1160        silver_gram_price (float): The price of a gram of silver.
1161        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1162                        it will be calculated based on the silver_gram_price.
1163        debug (bool): Flag to enable debug mode.
1164        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1165        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1166
1167        Returns:
1168        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1169        and a dictionary containing the Zakat plan.
1170        """
1171        if debug:
1172            print('check', f'debug={debug}')
1173        if now is None:
1174            now = self.time()
1175        if cycle is None:
1176            cycle = ZakatTracker.TimeCycle()
1177        if nisab is None:
1178            nisab = ZakatTracker.Nisab(silver_gram_price)
1179        plan = {}
1180        below_nisab = 0
1181        brief = [0, 0, 0]
1182        valid = False
1183        for x in self._vault['account']:
1184            if not self.zakatable(x):
1185                continue
1186            _box = self._vault['account'][x]['box']
1187            _log = self._vault['account'][x]['log']
1188            limit = len(_box) + 1
1189            ids = sorted(self._vault['account'][x]['box'].keys())
1190            for i in range(-1, -limit, -1):
1191                j = ids[i]
1192                rest = _box[j]['rest']
1193                if rest <= 0:
1194                    continue
1195                exchange = self.exchange(x, created=self.time())
1196                if debug:
1197                    print('exchanges', self.exchanges())
1198                rest = ZakatTracker.exchange_calc(rest, exchange['rate'], 1)
1199                brief[0] += rest
1200                index = limit + i - 1
1201                epoch = (now - j) / cycle
1202                if debug:
1203                    print(f"Epoch: {epoch}", _box[j])
1204                if _box[j]['last'] > 0:
1205                    epoch = (now - _box[j]['last']) / cycle
1206                if debug:
1207                    print(f"Epoch: {epoch}")
1208                epoch = floor(epoch)
1209                if debug:
1210                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1211                if epoch == 0:
1212                    continue
1213                if debug:
1214                    print("Epoch - PASSED")
1215                brief[1] += rest
1216                if rest >= nisab:
1217                    total = 0
1218                    for _ in range(epoch):
1219                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1220                    if total > 0:
1221                        if x not in plan:
1222                            plan[x] = {}
1223                        valid = True
1224                        brief[2] += total
1225                        plan[x][index] = {
1226                            'total': total,
1227                            'count': epoch,
1228                            'box_time': j,
1229                            'box_capital': _box[j]['capital'],
1230                            'box_rest': _box[j]['rest'],
1231                            'box_last': _box[j]['last'],
1232                            'box_total': _box[j]['total'],
1233                            'box_count': _box[j]['count'],
1234                            'box_log': _log[j]['desc'],
1235                            'exchange_rate': exchange['rate'],
1236                            'exchange_time': exchange['time'],
1237                            'exchange_desc': exchange['description'],
1238                        }
1239                else:
1240                    chunk = ZakatTracker.ZakatCut(float(rest))
1241                    if chunk > 0:
1242                        if x not in plan:
1243                            plan[x] = {}
1244                        if j not in plan[x].keys():
1245                            plan[x][index] = {}
1246                        below_nisab += rest
1247                        brief[2] += chunk
1248                        plan[x][index]['below_nisab'] = chunk
1249                        plan[x][index]['total'] = chunk
1250                        plan[x][index]['count'] = epoch
1251                        plan[x][index]['box_time'] = j
1252                        plan[x][index]['box_capital'] = _box[j]['capital']
1253                        plan[x][index]['box_rest'] = _box[j]['rest']
1254                        plan[x][index]['box_last'] = _box[j]['last']
1255                        plan[x][index]['box_total'] = _box[j]['total']
1256                        plan[x][index]['box_count'] = _box[j]['count']
1257                        plan[x][index]['box_log'] = _log[j]['desc']
1258                        plan[x][index]['exchange_rate'] = exchange['rate']
1259                        plan[x][index]['exchange_time'] = exchange['time']
1260                        plan[x][index]['exchange_desc'] = exchange['description']
1261        valid = valid or below_nisab >= nisab
1262        if debug:
1263            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1264        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:
1266    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1267        """
1268        Build payment parts for the Zakat distribution.
1269
1270        Parameters:
1271        demand (float): The total demand for payment in local currency.
1272        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1273
1274        Returns:
1275        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1276        {
1277            'account': {
1278                'account_id': {'balance': float, 'rate': float, 'part': float},
1279                ...
1280            },
1281            'exceed': bool,
1282            'demand': float,
1283            'total': float,
1284        }
1285        """
1286        total = 0
1287        parts = {
1288            'account': {},
1289            'exceed': False,
1290            'demand': demand,
1291        }
1292        for x, y in self.accounts().items():
1293            if positive_only and y <= 0:
1294                continue
1295            total += y
1296            exchange = self.exchange(x)
1297            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1298        parts['total'] = total
1299        return parts

Build payment parts for the Zakat distribution.

Parameters: demand (float): The total demand for payment in local currency. 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, 'rate': float, 'part': float}, ... }, 'exceed': bool, 'demand': float, 'total': float, }

@staticmethod
def check_payment_parts(parts: dict, debug: bool = False) -> int:
1301    @staticmethod
1302    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1303        """
1304        Checks the validity of payment parts.
1305
1306        Parameters:
1307        parts (dict): A dictionary containing payment parts information.
1308        debug (bool): Flag to enable debug mode.
1309
1310        Returns:
1311        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1312
1313        Error Codes:
1314        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1315        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1316        3: 'part' value in parts['account'][x] is less than 0.
1317        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1318        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1319        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1320        """
1321        if debug:
1322            print('check_payment_parts', f'debug={debug}')
1323        for i in ['demand', 'account', 'total', 'exceed']:
1324            if i not in parts:
1325                return 1
1326        exceed = parts['exceed']
1327        for x in parts['account']:
1328            for j in ['balance', 'rate', 'part']:
1329                if j not in parts['account'][x]:
1330                    return 2
1331                if parts['account'][x]['part'] < 0:
1332                    return 3
1333                if not exceed and parts['account'][x]['balance'] <= 0:
1334                    return 4
1335        demand = parts['demand']
1336        z = 0
1337        for _, y in parts['account'].items():
1338            if not exceed and y['part'] > y['balance']:
1339                return 5
1340            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1341        z = round(z, 2)
1342        demand = round(demand, 2)
1343        if debug:
1344            print('check_payment_parts', f'z = {z}, demand = {demand}')
1345            print('check_payment_parts', type(z), type(demand))
1346            print('check_payment_parts', z != demand)
1347            print('check_payment_parts', str(z) != str(demand))
1348        if z != demand and str(z) != str(demand):
1349            return 6
1350        return 0

Checks the validity of payment parts.

Parameters: parts (dict): A dictionary containing payment parts information. debug (bool): Flag to enable debug mode.

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', 'rate' or 'part' key is missing in parts['account'][x]. 3: 'part' value in parts['account'][x] is less than 0. 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 6: The sum of 'part' values in parts['account'] does not match with 'demand' value.

def zakat( self, report: tuple, parts: Dict[str, Union[Dict, bool, Any]] = None, debug: bool = False) -> bool:
1352    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1353        """
1354        Perform Zakat calculation based on the given report and optional parts.
1355
1356        Parameters:
1357        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1358        parts (dict): A dictionary containing the payment parts for the zakat.
1359        debug (bool): A flag indicating whether to print debug information.
1360
1361        Returns:
1362        bool: True if the zakat calculation is successful, False otherwise.
1363        """
1364        if debug:
1365            print('zakat', f'debug={debug}')
1366        valid, _, plan = report
1367        if not valid:
1368            return valid
1369        parts_exist = parts is not None
1370        if parts_exist:
1371            if self.check_payment_parts(parts, debug=debug) != 0:
1372                return False
1373        if debug:
1374            print('######### zakat #######')
1375            print('parts_exist', parts_exist)
1376        no_lock = self.nolock()
1377        self.lock()
1378        report_time = self.time()
1379        self._vault['report'][report_time] = report
1380        self._step(Action.REPORT, ref=report_time)
1381        created = self.time()
1382        for x in plan:
1383            target_exchange = self.exchange(x)
1384            if debug:
1385                print(plan[x])
1386                print('-------------')
1387                print(self._vault['account'][x]['box'])
1388            ids = sorted(self._vault['account'][x]['box'].keys())
1389            if debug:
1390                print('plan[x]', plan[x])
1391            for i in plan[x].keys():
1392                j = ids[i]
1393                if debug:
1394                    print('i', i, 'j', j)
1395                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1396                           key='last',
1397                           math_operation=MathOperation.EQUAL)
1398                self._vault['account'][x]['box'][j]['last'] = created
1399                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1400                self._vault['account'][x]['box'][j]['total'] += amount
1401                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1402                           math_operation=MathOperation.ADDITION)
1403                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1404                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1405                           math_operation=MathOperation.ADDITION)
1406                if not parts_exist:
1407                    try:
1408                        self._vault['account'][x]['box'][j]['rest'] -= amount
1409                    except TypeError:
1410                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1411                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1412                    #            math_operation=MathOperation.SUBTRACTION)
1413                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1414        if parts_exist:
1415            for account, part in parts['account'].items():
1416                if part['part'] == 0:
1417                    continue
1418                if debug:
1419                    print('zakat-part', account, part['rate'])
1420                target_exchange = self.exchange(account)
1421                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1422                self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug)
1423        if no_lock:
1424            self.free(self.lock())
1425        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:
1427    def export_json(self, path: str = "data.json") -> bool:
1428        """
1429        Exports the current state of the ZakatTracker object to a JSON file.
1430
1431        Parameters:
1432        path (str): The path where the JSON file will be saved. Default is "data.json".
1433
1434        Returns:
1435        bool: True if the export is successful, False otherwise.
1436
1437        Raises:
1438        No specific exceptions are raised by this method.
1439        """
1440        with open(path, "w") as file:
1441            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1442            return True

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:
1444    def save(self, path: str = None) -> bool:
1445        """
1446        Saves the ZakatTracker's current state to a pickle file.
1447
1448        This method serializes the internal data (`_vault`) along with metadata
1449        (Python version, pickle protocol) for future compatibility.
1450
1451        Parameters:
1452        path (str, optional): File path for saving. Defaults to a predefined location.
1453
1454        Returns:
1455        bool: True if the save operation is successful, False otherwise.
1456        """
1457        if path is None:
1458            path = self.path()
1459        with open(path, "wb") as f:
1460            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1461            pickle_protocol = pickle.HIGHEST_PROTOCOL
1462            data = {
1463                'python_version': version,
1464                'pickle_protocol': pickle_protocol,
1465                'data': self._vault,
1466            }
1467            pickle.dump(data, f, protocol=pickle_protocol)
1468            return True

Saves the ZakatTracker's current state to a pickle file.

This method serializes the internal data (_vault) along with metadata (Python version, pickle protocol) for future compatibility.

Parameters: path (str, optional): File path for saving. Defaults to a predefined location.

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

def load(self, path: str = None) -> bool:
1470    def load(self, path: str = None) -> bool:
1471        """
1472        Load the current state of the ZakatTracker object from a pickle file.
1473
1474        Parameters:
1475        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1476
1477        Returns:
1478        bool: True if the load operation is successful, False otherwise.
1479        """
1480        if path is None:
1481            path = self.path()
1482        if os.path.exists(path):
1483            with open(path, "rb") as f:
1484                data = pickle.load(f)
1485                self._vault = data['data']
1486                return True
1487        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_cache_path(self):
1489    def import_csv_cache_path(self):
1490        """
1491        Generates the cache file path for imported CSV data.
1492
1493        This function constructs the file path where cached data from CSV imports
1494        will be stored. The cache file is a pickle file (.pickle extension) appended
1495        to the base path of the object.
1496
1497        Returns:
1498        str: The full path to the import CSV cache file.
1499
1500        Example:
1501            >>> obj = ZakatTracker('/data/reports')
1502            >>> obj.import_csv_cache_path()
1503            '/data/reports.import_csv.pickle'
1504        """
1505        path = self.path()
1506        if path.endswith(".pickle"):
1507            path = path[:-7]
1508        return path + '.import_csv.pickle'

Generates the cache file path for imported CSV data.

This function constructs the file path where cached data from CSV imports will be stored. The cache file is a pickle file (.pickle extension) appended to the base path of the object.

Returns: str: The full path to the import CSV cache file.

Example:

obj = ZakatTracker('/data/reports') obj.import_csv_cache_path() '/data/reports.import_csv.pickle'

def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1510    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1511        """
1512        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1513
1514        Parameters:
1515        path (str): The path to the CSV file. Default is 'file.csv'.
1516        debug (bool): A flag indicating whether to print debug information.
1517
1518        Returns:
1519        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1520                and a dictionary of bad transactions.
1521
1522        Notes:
1523            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1524                                        are appropriate for the currency pairs involved in the conversions.
1525            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1526                to 1.0 or the previous rate for that account.
1527            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1528              transactions of the same account within the whole imported and existing dataset when doing `check` and
1529              `zakat` operations.
1530
1531        Example Usage:
1532            The CSV file should have the following format, rate is optional per transaction:
1533            account, desc, value, date, rate
1534            For example:
1535            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1536        """
1537        if debug:
1538            print('import_csv', f'debug={debug}')
1539        cache: list[int] = []
1540        try:
1541            with open(self.import_csv_cache_path(), "rb") as f:
1542                cache = pickle.load(f)
1543        except:
1544            pass
1545        date_formats = [
1546            "%Y-%m-%d %H:%M:%S",
1547            "%Y-%m-%dT%H:%M:%S",
1548            "%Y-%m-%dT%H%M%S",
1549            "%Y-%m-%d",
1550        ]
1551        created, found, bad = 0, 0, {}
1552        data: list[tuple] = []
1553        with open(path, newline='', encoding="utf-8") as f:
1554            i = 0
1555            for row in csv.reader(f, delimiter=','):
1556                i += 1
1557                hashed = hash(tuple(row))
1558                if hashed in cache:
1559                    found += 1
1560                    continue
1561                account = row[0]
1562                desc = row[1]
1563                value = float(row[2])
1564                rate = 1.0
1565                if row[4:5]:  # Empty list if index is out of range
1566                    rate = float(row[4])
1567                date: int = 0
1568                for time_format in date_formats:
1569                    try:
1570                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1571                        break
1572                    except:
1573                        pass
1574                # TODO: not allowed for negative dates
1575                if date == 0 or value == 0:
1576                    bad[i] = row
1577                    continue
1578                if date in data:
1579                    print('import_csv-duplicated(time)', date)
1580                    continue
1581                data.append((date, value, desc, account, rate, hashed))
1582
1583        if debug:
1584            print('import_csv', len(data))
1585        for row in sorted(data, key=lambda x: x[0]):
1586            (date, value, desc, account, rate, hashed) = row
1587            if rate > 1:
1588                self.exchange(account, created=date, rate=rate)
1589            if value > 0:
1590                self.track(value, desc, account, True, date)
1591            elif value < 0:
1592                self.sub(-value, desc, account, date)
1593            created += 1
1594            cache.append(hashed)
1595        with open(self.import_csv_cache_path(), "wb") as f:
1596            pickle.dump(cache, f)
1597        return created, found, bad

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

Parameters: path (str): The path to the CSV file. Default is 'file.csv'. debug (bool): A flag indicating whether to print debug information.

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

Notes: * Currency Pair Assumption: This function assumes that the exchange rates stored for each account are appropriate for the currency pairs involved in the conversions. * The exchange rate for each account is based on the last encountered transaction rate that is not equal to 1.0 or the previous rate for that account. * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent transactions of the same account within the whole imported and existing dataset when doing check and zakat operations.

Example Usage: The CSV file should have the following format, rate is optional per transaction: account, desc, value, date, rate For example: safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1

@staticmethod
def duration_from_nanoseconds(ns: int) -> tuple:
1603    @staticmethod
1604    def duration_from_nanoseconds(ns: int) -> tuple:
1605        """
1606        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1607        Convert NanoSeconds to Human Readable Time Format.
1608        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1609        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1610        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1611        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1612
1613        INPUT : ms (AKA: MilliSeconds)
1614        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1615        OUTPUT Variables: time_lapsed, spoken_time
1616
1617        Example  Input: duration_from_nanoseconds(ns)
1618        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1619        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')
1620        duration_from_nanoseconds(1234567890123456789012)
1621        """
1622        us, ns = divmod(ns, 1000)
1623        ms, us = divmod(us, 1000)
1624        s, ms = divmod(ms, 1000)
1625        m, s = divmod(s, 60)
1626        h, m = divmod(m, 60)
1627        d, h = divmod(h, 24)
1628        y, d = divmod(d, 365)
1629        c, y = divmod(y, 100)
1630        n, c = divmod(c, 10)
1631        time_lapsed = 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}::{us:03.0f}::{ns:03.0f}"
1632        spoken_time = 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, {us: 3d} MicroSeconds, {ns: 3d} NanoSeconds"
1633        return time_lapsed, spoken_time

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 time_lapsed, string spoken_time) like format. OUTPUT Variables: time_lapsed, spoken_time

Example Input: duration_from_nanoseconds(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') duration_from_nanoseconds(1234567890123456789012)

@staticmethod
def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:
1635    @staticmethod
1636    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
1637        """
1638        Convert a specific day, month, and year into a timestamp.
1639
1640        Parameters:
1641        day (int): The day of the month.
1642        month (int): The month of the year. Default is 6 (June).
1643        year (int): The year. Default is 2024.
1644
1645        Returns:
1646        int: The timestamp representing the given day, month, and year.
1647
1648        Note:
1649        This method assumes the default month and year if not provided.
1650        """
1651        return ZakatTracker.time(datetime.datetime(year, month, day))

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

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

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

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

@staticmethod
def generate_random_date( start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1653    @staticmethod
1654    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1655        """
1656        Generate a random date between two given dates.
1657
1658        Parameters:
1659        start_date (datetime.datetime): The start date from which to generate a random date.
1660        end_date (datetime.datetime): The end date until which to generate a random date.
1661
1662        Returns:
1663        datetime.datetime: A random date between the start_date and end_date.
1664        """
1665        time_between_dates = end_date - start_date
1666        days_between_dates = time_between_dates.days
1667        random_number_of_days = random.randrange(days_between_dates)
1668        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, with_rate: bool = False, debug: bool = False) -> int:
1670    @staticmethod
1671    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
1672                                 debug: bool = False) -> int:
1673        """
1674        Generate a random CSV file with specified parameters.
1675
1676        Parameters:
1677        path (str): The path where the CSV file will be saved. Default is "data.csv".
1678        count (int): The number of rows to generate in the CSV file. Default is 1000.
1679        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
1680        debug (bool): A flag indicating whether to print debug information.
1681
1682        Returns:
1683        None. The function generates a CSV file at the specified path with the given count of rows.
1684        Each row contains a randomly generated account, description, value, and date.
1685        The value is randomly generated between 1000 and 100000,
1686        and the date is randomly generated between 1950-01-01 and 2023-12-31.
1687        If the row number is not divisible by 13, the value is multiplied by -1.
1688        """
1689        if debug:
1690            print('generate_random_csv_file', f'debug={debug}')
1691        i = 0
1692        with open(path, "w", newline="") as csvfile:
1693            writer = csv.writer(csvfile)
1694            for i in range(count):
1695                account = f"acc-{random.randint(1, 1000)}"
1696                desc = f"Some text {random.randint(1, 1000)}"
1697                value = random.randint(1000, 100000)
1698                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
1699                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1700                if not i % 13 == 0:
1701                    value *= -1
1702                row = [account, desc, value, date]
1703                if with_rate:
1704                    rate = random.randint(1, 100) * 0.12
1705                    if debug:
1706                        print('before-append', row)
1707                    row.append(rate)
1708                    if debug:
1709                        print('after-append', row)
1710                writer.writerow(row)
1711                i = i + 1
1712        return i

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. with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. debug (bool): A flag indicating whether to print debug information.

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

@staticmethod
def create_random_list(max_sum, min_value=0, max_value=10):
1714    @staticmethod
1715    def create_random_list(max_sum, min_value=0, max_value=10):
1716        """
1717        Creates a list of random integers whose sum does not exceed the specified maximum.
1718
1719        Args:
1720            max_sum: The maximum allowed sum of the list elements.
1721            min_value: The minimum possible value for an element (inclusive).
1722            max_value: The maximum possible value for an element (inclusive).
1723
1724        Returns:
1725            A list of random integers.
1726        """
1727        result = []
1728        current_sum = 0
1729
1730        while current_sum < max_sum:
1731            # Calculate the remaining space for the next element
1732            remaining_sum = max_sum - current_sum
1733            # Determine the maximum possible value for the next element
1734            next_max_value = min(remaining_sum, max_value)
1735            # Generate a random element within the allowed range
1736            next_element = random.randint(min_value, next_max_value)
1737            result.append(next_element)
1738            current_sum += next_element
1739
1740        return result

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

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

Returns: A list of random integers.

def test(self, debug: bool = False) -> bool:
1885    def test(self, debug: bool = False) -> bool:
1886        if debug:
1887            print('test', f'debug={debug}')
1888        try:
1889
1890            assert self._history()
1891
1892            # Not allowed for duplicate transactions in the same account and time
1893
1894            created = ZakatTracker.time()
1895            self.track(100, 'test-1', 'same', True, created)
1896            failed = False
1897            try:
1898                self.track(50, 'test-1', 'same', True, created)
1899            except:
1900                failed = True
1901            assert failed is True
1902
1903            self.reset()
1904
1905            # Same account transfer
1906            for x in [1, 'a', True, 1.8, None]:
1907                failed = False
1908                try:
1909                    self.transfer(1, x, x, 'same-account', debug=debug)
1910                except:
1911                    failed = True
1912                assert failed is True
1913
1914            # Always preserve box age during transfer
1915
1916            series: list[tuple] = [
1917                (30, 4),
1918                (60, 3),
1919                (90, 2),
1920            ]
1921            case = {
1922                30: {
1923                    'series': series,
1924                    'rest': 150,
1925                },
1926                60: {
1927                    'series': series,
1928                    'rest': 120,
1929                },
1930                90: {
1931                    'series': series,
1932                    'rest': 90,
1933                },
1934                180: {
1935                    'series': series,
1936                    'rest': 0,
1937                },
1938                270: {
1939                    'series': series,
1940                    'rest': -90,
1941                },
1942                360: {
1943                    'series': series,
1944                    'rest': -180,
1945                },
1946            }
1947
1948            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
1949
1950            for total in case:
1951                for x in case[total]['series']:
1952                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
1953
1954                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
1955
1956                if debug:
1957                    print('refs', refs)
1958
1959                ages_cache_balance = self.balance('ages')
1960                ages_fresh_balance = self.balance('ages', False)
1961                rest = case[total]['rest']
1962                if debug:
1963                    print('source', ages_cache_balance, ages_fresh_balance, rest)
1964                assert ages_cache_balance == rest
1965                assert ages_fresh_balance == rest
1966
1967                future_cache_balance = self.balance('future')
1968                future_fresh_balance = self.balance('future', False)
1969                if debug:
1970                    print('target', future_cache_balance, future_fresh_balance, total)
1971                    print('refs', refs)
1972                assert future_cache_balance == total
1973                assert future_fresh_balance == total
1974
1975                for ref in self._vault['account']['ages']['box']:
1976                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
1977                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
1978                    future_capital = 0
1979                    if ref in self._vault['account']['future']['box']:
1980                        future_capital = self._vault['account']['future']['box'][ref]['capital']
1981                    future_rest = 0
1982                    if ref in self._vault['account']['future']['box']:
1983                        future_rest = self._vault['account']['future']['box'][ref]['rest']
1984                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
1985                        if debug:
1986                            print('================================================================')
1987                            print('ages', ages_capital, ages_rest)
1988                            print('future', future_capital, future_rest)
1989                        if ages_rest == 0:
1990                            assert ages_capital == future_capital
1991                        elif ages_rest < 0:
1992                            assert -ages_capital == future_capital
1993                        elif ages_rest > 0:
1994                            assert ages_capital == ages_rest + future_capital
1995                self.reset()
1996                assert len(self._vault['history']) == 0
1997
1998            assert self._history()
1999            assert self._history(False) is False
2000            assert self._history() is False
2001            assert self._history(True)
2002            assert self._history()
2003
2004            self._test_core(True, debug)
2005            self._test_core(False, debug)
2006
2007            transaction = [
2008                (
2009                    20, 'wallet', 1, 800, 800, 800, 4, 5,
2010                    -85, -85, -85, 6, 7,
2011                ),
2012                (
2013                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
2014                    750, 750, 750, 1, 1,
2015                ),
2016                (
2017                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
2018                    600, 600, 600, 1, 1,
2019                ),
2020            ]
2021            for z in transaction:
2022                self.lock()
2023                x = z[1]
2024                y = z[2]
2025                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
2026                assert self.balance(x) == z[3]
2027                xx = self.accounts()[x]
2028                assert xx == z[3]
2029                assert self.balance(x, False) == z[4]
2030                assert xx == z[4]
2031
2032                s = 0
2033                log = self._vault['account'][x]['log']
2034                for i in log:
2035                    s += log[i]['value']
2036                if debug:
2037                    print('s', s, 'z[5]', z[5])
2038                assert s == z[5]
2039
2040                assert self.box_size(x) == z[6]
2041                assert self.log_size(x) == z[7]
2042
2043                yy = self.accounts()[y]
2044                assert self.balance(y) == z[8]
2045                assert yy == z[8]
2046                assert self.balance(y, False) == z[9]
2047                assert yy == z[9]
2048
2049                s = 0
2050                log = self._vault['account'][y]['log']
2051                for i in log:
2052                    s += log[i]['value']
2053                assert s == z[10]
2054
2055                assert self.box_size(y) == z[11]
2056                assert self.log_size(y) == z[12]
2057
2058            if debug:
2059                pp().pprint(self.check(2.17))
2060
2061            assert not self.nolock()
2062            history_count = len(self._vault['history'])
2063            if debug:
2064                print('history-count', history_count)
2065            assert history_count == 11
2066            assert not self.free(ZakatTracker.time())
2067            assert self.free(self.lock())
2068            assert self.nolock()
2069            assert len(self._vault['history']) == 11
2070
2071            # storage
2072
2073            _path = self.path('test.pickle')
2074            if os.path.exists(_path):
2075                os.remove(_path)
2076            self.save()
2077            assert os.path.getsize(_path) > 0
2078            self.reset()
2079            assert self.recall(False, debug) is False
2080            self.load()
2081            assert self._vault['account'] is not None
2082
2083            # recall
2084
2085            assert self.nolock()
2086            assert len(self._vault['history']) == 11
2087            assert self.recall(False, debug) is True
2088            assert len(self._vault['history']) == 10
2089            assert self.recall(False, debug) is True
2090            assert len(self._vault['history']) == 9
2091
2092            # exchange
2093
2094            self.exchange("cash", 25, 3.75, "2024-06-25")
2095            self.exchange("cash", 22, 3.73, "2024-06-22")
2096            self.exchange("cash", 15, 3.69, "2024-06-15")
2097            self.exchange("cash", 10, 3.66)
2098
2099            for i in range(1, 30):
2100                exchange = self.exchange("cash", i)
2101                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2102                if debug:
2103                    print(i, rate, description, created)
2104                assert created
2105                if i < 10:
2106                    assert rate == 1
2107                    assert description is None
2108                elif i == 10:
2109                    assert rate == 3.66
2110                    assert description is None
2111                elif i < 15:
2112                    assert rate == 3.66
2113                    assert description is None
2114                elif i == 15:
2115                    assert rate == 3.69
2116                    assert description is not None
2117                elif i < 22:
2118                    assert rate == 3.69
2119                    assert description is not None
2120                elif i == 22:
2121                    assert rate == 3.73
2122                    assert description is not None
2123                elif i >= 25:
2124                    assert rate == 3.75
2125                    assert description is not None
2126                exchange = self.exchange("bank", i)
2127                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2128                if debug:
2129                    print(i, rate, description, created)
2130                assert created
2131                assert rate == 1
2132                assert description is None
2133
2134            assert len(self._vault['exchange']) > 0
2135            assert len(self.exchanges()) > 0
2136            self._vault['exchange'].clear()
2137            assert len(self._vault['exchange']) == 0
2138            assert len(self.exchanges()) == 0
2139
2140            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2141            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2142            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2143            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2144            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2145
2146            for i in [x * 0.12 for x in range(-15, 21)]:
2147                if i <= 0:
2148                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2149                else:
2150                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2151
2152            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2153            for i in range(1, 31):
2154                timestamp_ns = ZakatTracker.day_to_time(i)
2155                exchange = self.exchange("cash", timestamp_ns)
2156                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2157                if debug:
2158                    print(i, rate, description, created)
2159                assert created
2160                if i < 10:
2161                    assert rate == 1
2162                    assert description is None
2163                elif i == 10:
2164                    assert rate == 3.66
2165                    assert description is None
2166                elif i < 15:
2167                    assert rate == 3.66
2168                    assert description is None
2169                elif i == 15:
2170                    assert rate == 3.69
2171                    assert description is not None
2172                elif i < 22:
2173                    assert rate == 3.69
2174                    assert description is not None
2175                elif i == 22:
2176                    assert rate == 3.73
2177                    assert description is not None
2178                elif i >= 25:
2179                    assert rate == 3.75
2180                    assert description is not None
2181                exchange = self.exchange("bank", i)
2182                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2183                if debug:
2184                    print(i, rate, description, created)
2185                assert created
2186                assert rate == 1
2187                assert description is None
2188
2189            # csv
2190
2191            csv_count = 1000
2192
2193            for with_rate, path in {
2194                False: 'test-import_csv-no-exchange',
2195                True: 'test-import_csv-with-exchange',
2196            }.items():
2197
2198                if debug:
2199                    print('test_import_csv', with_rate, path)
2200
2201                # csv
2202
2203                csv_path = path + '.csv'
2204                if os.path.exists(csv_path):
2205                    os.remove(csv_path)
2206                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2207                if debug:
2208                    print('generate_random_csv_file', c)
2209                assert c == csv_count
2210                assert os.path.getsize(csv_path) > 0
2211                cache_path = self.import_csv_cache_path()
2212                if os.path.exists(cache_path):
2213                    os.remove(cache_path)
2214                self.reset()
2215                (created, found, bad) = self.import_csv(csv_path, debug)
2216                bad_count = len(bad)
2217                if debug:
2218                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2219                tmp_size = os.path.getsize(cache_path)
2220                assert tmp_size > 0
2221                assert created + found + bad_count == csv_count
2222                assert created == csv_count
2223                assert bad_count == 0
2224                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2225                bad_2_count = len(bad_2)
2226                if debug:
2227                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2228                    print(bad)
2229                assert tmp_size == os.path.getsize(cache_path)
2230                assert created_2 + found_2 + bad_2_count == csv_count
2231                assert created == found_2
2232                assert bad_count == bad_2_count
2233                assert found_2 == csv_count
2234                assert bad_2_count == 0
2235                assert created_2 == 0
2236
2237                # payment parts
2238
2239                positive_parts = self.build_payment_parts(100, positive_only=True)
2240                assert self.check_payment_parts(positive_parts) != 0
2241                assert self.check_payment_parts(positive_parts) != 0
2242                all_parts = self.build_payment_parts(300, positive_only=False)
2243                assert self.check_payment_parts(all_parts) != 0
2244                assert self.check_payment_parts(all_parts) != 0
2245                if debug:
2246                    pp().pprint(positive_parts)
2247                    pp().pprint(all_parts)
2248                # dynamic discount
2249                suite = []
2250                count = 3
2251                for exceed in [False, True]:
2252                    case = []
2253                    for parts in [positive_parts, all_parts]:
2254                        part = parts.copy()
2255                        demand = part['demand']
2256                        if debug:
2257                            print(demand, part['total'])
2258                        i = 0
2259                        z = demand / count
2260                        cp = {
2261                            'account': {},
2262                            'demand': demand,
2263                            'exceed': exceed,
2264                            'total': part['total'],
2265                        }
2266                        j = ''
2267                        for x, y in part['account'].items():
2268                            x_exchange = self.exchange(x)
2269                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2270                            if exceed and zz <= demand:
2271                                i += 1
2272                                y['part'] = zz
2273                                if debug:
2274                                    print(exceed, y)
2275                                cp['account'][x] = y
2276                                case.append(y)
2277                            elif not exceed and y['balance'] >= zz:
2278                                i += 1
2279                                y['part'] = zz
2280                                if debug:
2281                                    print(exceed, y)
2282                                cp['account'][x] = y
2283                                case.append(y)
2284                            j = x
2285                            if i >= count:
2286                                break
2287                        if len(cp['account'][j]) > 0:
2288                            suite.append(cp)
2289                if debug:
2290                    print('suite', len(suite))
2291                # vault = self._vault.copy()
2292                for case in suite:
2293                    # self._vault = vault.copy()
2294                    if debug:
2295                        print('case', case)
2296                    result = self.check_payment_parts(case)
2297                    if debug:
2298                        print('check_payment_parts', result, f'exceed: {exceed}')
2299                    assert result == 0
2300
2301                    report = self.check(2.17, None, debug)
2302                    (valid, brief, plan) = report
2303                    if debug:
2304                        print('valid', valid)
2305                    zakat_result = self.zakat(report, parts=case, debug=debug)
2306                    if debug:
2307                        print('zakat-result', zakat_result)
2308                    assert valid == zakat_result
2309
2310            assert self.save(path + '.pickle')
2311            assert self.export_json(path + '.json')
2312
2313            assert self.export_json("1000-transactions-test.json")
2314            assert self.save("1000-transactions-test.pickle")
2315
2316            self.reset()
2317
2318            # test transfer between accounts with different exchange rate
2319
2320            a_SAR = "Bank (SAR)"
2321            b_USD = "Bank (USD)"
2322            c_SAR = "Safe (SAR)"
2323            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2324            for case in [
2325                (0, a_SAR, "SAR Gift", 1000, 1000),
2326                (1, a_SAR, 1),
2327                (0, b_USD, "USD Gift", 500, 500),
2328                (1, b_USD, 1),
2329                (2, b_USD, 3.75),
2330                (1, b_USD, 3.75),
2331                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2332                (0, c_SAR, "Salary", 750, 750),
2333                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2334                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2335            ]:
2336                match (case[0]):
2337                    case 0:  # track
2338                        _, account, desc, x, balance = case
2339                        self.track(value=x, desc=desc, account=account, debug=debug)
2340
2341                        cached_value = self.balance(account, cached=True)
2342                        fresh_value = self.balance(account, cached=False)
2343                        if debug:
2344                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2345                        assert cached_value == balance
2346                        assert fresh_value == balance
2347                    case 1:  # check-exchange
2348                        _, account, expected_rate = case
2349                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2350                        if debug:
2351                            print('t-exchange', t_exchange)
2352                        assert t_exchange['rate'] == expected_rate
2353                    case 2:  # do-exchange
2354                        _, account, rate = case
2355                        self.exchange(account, rate=rate, debug=debug)
2356                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2357                        if debug:
2358                            print('b-exchange', b_exchange)
2359                        assert b_exchange['rate'] == rate
2360                    case 3:  # transfer
2361                        _, x, a, b, desc, a_balance, b_balance = case
2362                        self.transfer(x, a, b, desc, debug=debug)
2363
2364                        cached_value = self.balance(a, cached=True)
2365                        fresh_value = self.balance(a, cached=False)
2366                        if debug:
2367                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2368                        assert cached_value == a_balance
2369                        assert fresh_value == a_balance
2370
2371                        cached_value = self.balance(b, cached=True)
2372                        fresh_value = self.balance(b, cached=False)
2373                        if debug:
2374                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2375                        assert cached_value == b_balance
2376                        assert fresh_value == b_balance
2377
2378            # Transfer all in many chunks randomly from B to A
2379            a_SAR_balance = 1371.25
2380            b_USD_balance = 501
2381            b_USD_exchange = self.exchange(b_USD)
2382            amounts = ZakatTracker.create_random_list(b_USD_balance)
2383            if debug:
2384                print('amounts', amounts)
2385            i = 0
2386            for x in amounts:
2387                if debug:
2388                    print(f'{i} - transfer-with-exchange({x})')
2389                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2390
2391                b_USD_balance -= x
2392                cached_value = self.balance(b_USD, cached=True)
2393                fresh_value = self.balance(b_USD, cached=False)
2394                if debug:
2395                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2396                          b_USD_balance)
2397                assert cached_value == b_USD_balance
2398                assert fresh_value == b_USD_balance
2399
2400                a_SAR_balance += x * b_USD_exchange['rate']
2401                cached_value = self.balance(a_SAR, cached=True)
2402                fresh_value = self.balance(a_SAR, cached=False)
2403                if debug:
2404                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2405                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2406                assert cached_value == a_SAR_balance
2407                assert fresh_value == a_SAR_balance
2408                i += 1
2409
2410            # Transfer all in many chunks randomly from C to A
2411            c_SAR_balance = 375
2412            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2413            if debug:
2414                print('amounts', amounts)
2415            i = 0
2416            for x in amounts:
2417                if debug:
2418                    print(f'{i} - transfer-with-exchange({x})')
2419                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2420
2421                c_SAR_balance -= x
2422                cached_value = self.balance(c_SAR, cached=True)
2423                fresh_value = self.balance(c_SAR, cached=False)
2424                if debug:
2425                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2426                          c_SAR_balance)
2427                assert cached_value == c_SAR_balance
2428                assert fresh_value == c_SAR_balance
2429
2430                a_SAR_balance += x
2431                cached_value = self.balance(a_SAR, cached=True)
2432                fresh_value = self.balance(a_SAR, cached=False)
2433                if debug:
2434                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2435                          a_SAR_balance)
2436                assert cached_value == a_SAR_balance
2437                assert fresh_value == a_SAR_balance
2438                i += 1
2439
2440            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2441            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2442
2443            # check & zakat with exchange rates for many cycles
2444
2445            for rate, values in {
2446                1: {
2447                    'in': [1000, 2000, 10000],
2448                    'exchanged': [1000, 2000, 10000],
2449                    'out': [25, 50, 731.40625],
2450                },
2451                3.75: {
2452                    'in': [200, 1000, 5000],
2453                    'exchanged': [750, 3750, 18750],
2454                    'out': [18.75, 93.75, 1371.38671875],
2455                },
2456            }.items():
2457                a, b, c = values['in']
2458                m, n, o = values['exchanged']
2459                x, y, z = values['out']
2460                if debug:
2461                    print('rate', rate, 'values', values)
2462                for case in [
2463                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2464                        {'safe': {0: {'below_nisab': x}}},
2465                    ], False, m),
2466                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2467                        {'safe': {0: {'count': 1, 'total': y}}},
2468                    ], True, n),
2469                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2470                        {'cave': {0: {'count': 3, 'total': z}}},
2471                    ], True, o),
2472                ]:
2473                    if debug:
2474                        print(f"############# check(rate: {rate}) #############")
2475                    self.reset()
2476                    self.exchange(account=case[1], created=case[2], rate=rate)
2477                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2478
2479                    # assert self.nolock()
2480                    # history_size = len(self._vault['history'])
2481                    # print('history_size', history_size)
2482                    # assert history_size == 2
2483                    assert self.lock()
2484                    assert not self.nolock()
2485                    report = self.check(2.17, None, debug)
2486                    (valid, brief, plan) = report
2487                    assert valid == case[4]
2488                    if debug:
2489                        print('brief', brief)
2490                    assert case[5] == brief[0]
2491                    assert case[5] == brief[1]
2492
2493                    if debug:
2494                        pp().pprint(plan)
2495
2496                    for x in plan:
2497                        assert case[1] == x
2498                        if 'total' in case[3][0][x][0].keys():
2499                            assert case[3][0][x][0]['total'] == brief[2]
2500                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2501                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2502                        else:
2503                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2504                    if debug:
2505                        pp().pprint(report)
2506                    result = self.zakat(report, debug=debug)
2507                    if debug:
2508                        print('zakat-result', result, case[4])
2509                    assert result == case[4]
2510                    report = self.check(2.17, None, debug)
2511                    (valid, brief, plan) = report
2512                    assert valid is False
2513
2514            history_size = len(self._vault['history'])
2515            if debug:
2516                print('history_size', history_size)
2517            assert history_size == 3
2518            assert not self.nolock()
2519            assert self.recall(False, debug) is False
2520            self.free(self.lock())
2521            assert self.nolock()
2522
2523            for i in range(3, 0, -1):
2524                history_size = len(self._vault['history'])
2525                if debug:
2526                    print('history_size', history_size)
2527                assert history_size == i
2528                assert self.recall(False, debug) is True
2529
2530            assert self.nolock()
2531            assert self.recall(False, debug) is False
2532
2533            history_size = len(self._vault['history'])
2534            if debug:
2535                print('history_size', history_size)
2536            assert history_size == 0
2537
2538            account_size = len(self._vault['account'])
2539            if debug:
2540                print('account_size', account_size)
2541            assert account_size == 0
2542
2543            report_size = len(self._vault['report'])
2544            if debug:
2545                print('report_size', report_size)
2546            assert report_size == 0
2547
2548            assert self.nolock()
2549            return True
2550        except:
2551            # pp().pprint(self._vault)
2552            assert self.export_json("test-snapshot.json")
2553            assert self.save("test-snapshot.pickle")
2554            raise
def test(debug: bool = False):
2557def test(debug: bool = False):
2558    ledger = ZakatTracker()
2559    start = ZakatTracker.time()
2560    assert ledger.test(debug=debug)
2561    if debug:
2562        print("#########################")
2563        print("######## TEST DONE ########")
2564        print("#########################")
2565        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
2566        print("#########################")
def main():
2569def main():
2570    test(debug=True)