zakat
xxx

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

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

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

 1"""
 2 _____     _         _     _     _ _                          
 3|__  /__ _| | ____ _| |_  | |   (_) |__  _ __ __ _ _ __ _   _ 
 4  / // _` | |/ / _` | __| | |   | | '_ \| '__/ _` | '__| | | |
 5 / /| (_| |   < (_| | |_  | |___| | |_) | | | (_| | |  | |_| |
 6/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_|  \__,_|_|   \__, |
 7                                                        |___/ 
 8
 9"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف
10... Never Trust, Always Verify ...
11
12This file provides the ZakatLibrary classes, functions for tracking and calculating Zakat.
13"""
14# Importing necessary classes and functions from the main module
15from zakat.zakat_tracker import (
16    ZakatTracker,
17    test,
18    Action,
19    JSONEncoder,
20    MathOperation,
21)
22
23from zakat.file_server import (
24    start_file_server,
25    find_available_port,
26    FileType,
27)
28
29# Version information for the module
30__version__ = ZakatTracker.Version()
31__all__ = [
32    "ZakatTracker",
33    "test",
34    "Action",
35    "JSONEncoder",
36    "MathOperation",
37    "start_file_server",
38    "find_available_port",
39    "FileType",
40]
class ZakatTracker:
 104class ZakatTracker:
 105    """
 106    A class for tracking and calculating Zakat.
 107
 108    This class provides functionalities for recording transactions, calculating Zakat due,
 109    and managing account balances. It also offers features like importing transactions from
 110    CSV files, exporting data to JSON format, and saving/loading the tracker state.
 111
 112    The `ZakatTracker` class is designed to handle both positive and negative transactions,
 113    allowing for flexible tracking of financial activities related to Zakat. It also supports
 114    the concept of a "Nisab" (minimum threshold for Zakat) and a "haul" (complete one year for Transaction) can calculate Zakat due
 115    based on the current silver price.
 116
 117    The class uses a pickle file as its database to persist the tracker state,
 118    ensuring data integrity across sessions. It also provides options for enabling or
 119    disabling history tracking, allowing users to choose their preferred level of detail.
 120
 121    In addition, the `ZakatTracker` class includes various helper methods like
 122    `time`, `time_to_datetime`, `lock`, `free`, `recall`, `export_json`,
 123    and more. These methods provide additional functionalities and flexibility
 124    for interacting with and managing the Zakat tracker.
 125
 126    Attributes:
 127        ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage.
 128        ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat.
 129        ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price.
 130        ZakatTracker.Version (function): The version of the ZakatTracker class.
 131
 132    Data Structure:
 133        The ZakatTracker class utilizes a nested dictionary structure called "_vault" to store and manage data.
 134
 135        _vault (dict):
 136            - account (dict):
 137                - {account_number} (dict):
 138                    - balance (int): The current balance of the account.
 139                    - box (dict): A dictionary storing transaction details.
 140                        - {timestamp} (dict):
 141                            - capital (int): The initial amount of the transaction.
 142                            - count (int): The number of times Zakat has been calculated for this transaction.
 143                            - last (int): The timestamp of the last Zakat calculation.
 144                            - rest (int): The remaining amount after Zakat deductions and withdrawal.
 145                            - total (int): The total Zakat deducted from this transaction.
 146                    - count (int): The total number of transactions for the account.
 147                    - log (dict): A dictionary storing transaction logs.
 148                        - {timestamp} (dict):
 149                            - value (int): The transaction amount (positive or negative).
 150                            - desc (str): The description of the transaction.
 151                            - ref (int): The box reference (positive or None).
 152                            - file (dict): A dictionary storing file references associated with the transaction.
 153                    - hide (bool): Indicates whether the account is hidden or not.
 154                    - zakatable (bool): Indicates whether the account is subject to Zakat.
 155            - exchange (dict):
 156                - account (dict):
 157                    - {timestamps} (dict):
 158                        - rate (float): Exchange rate when compared to local currency.
 159                        - description (str): The description of the exchange rate.
 160            - history (dict):
 161                - {timestamp} (list): A list of dictionaries storing the history of actions performed.
 162                    - {action_dict} (dict):
 163                        - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
 164                        - account (str): The account number associated with the action.
 165                        - ref (int): The reference number of the transaction.
 166                        - file (int): The reference number of the file (if applicable).
 167                        - key (str): The key associated with the action (e.g., 'rest', 'total').
 168                        - value (int): The value associated with the action.
 169                        - math (MathOperation): The mathematical operation performed (if applicable).
 170            - lock (int or None): The timestamp indicating the current lock status (None if not locked).
 171            - report (dict):
 172                - {timestamp} (tuple): A tuple storing Zakat report details.
 173
 174    """
 175
 176    @staticmethod
 177    def Version() -> str:
 178        """
 179        Returns the current version of the software.
 180
 181        This function returns a string representing the current version of the software,
 182        including major, minor, and patch version numbers in the format "X.Y.Z".
 183
 184        Returns:
 185        str: The current version of the software.
 186        """
 187        return '0.2.81'
 188
 189    @staticmethod
 190    def ZakatCut(x: float) -> float:
 191        """
 192        Calculates the Zakat amount due on an asset.
 193
 194        This function calculates the zakat amount due on a given asset value over one lunar year.
 195        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
 196        that exceeds a certain threshold (Nisab).
 197
 198        Parameters:
 199        x: The total value of the asset on which Zakat is to be calculated.
 200
 201        Returns:
 202        The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
 203        """
 204        return 0.025 * x  # Zakat Cut in one Lunar Year
 205
 206    @staticmethod
 207    def TimeCycle(days: int = 355) -> int:
 208        """
 209        Calculates the approximate duration of a lunar year in nanoseconds.
 210
 211        This function calculates the approximate duration of a lunar year based on the given number of days.
 212        It converts the given number of days into nanoseconds for use in high-precision timing applications.
 213
 214        Parameters:
 215        days: The number of days in a lunar year. Defaults to 355,
 216              which is an approximation of the average length of a lunar year.
 217
 218        Returns:
 219        The approximate duration of a lunar year in nanoseconds.
 220        """
 221        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds
 222
 223    @staticmethod
 224    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
 225        """
 226        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
 227
 228        This function calculates the Nisab value, which is the minimum threshold of wealth,
 229        that makes an individual liable for paying Zakat.
 230        The Nisab value is determined by the equivalent value of a specific amount
 231        of gold or silver (currently 595 grams in silver) in the local currency.
 232
 233        Parameters:
 234        - gram_price (float): The price per gram of Nisab.
 235        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
 236
 237        Returns:
 238        - float: The total value of Nisab based on the given price per gram.
 239        """
 240        return gram_price * gram_quantity
 241
 242    def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True):
 243        """
 244        Initialize ZakatTracker with database path and history mode.
 245
 246        Parameters:
 247        db_path (str): The path to the database file. Default is "zakat.pickle".
 248        history_mode (bool): The mode for tracking history. Default is True.
 249
 250        Returns:
 251        None
 252        """
 253        self._base_path = None
 254        self._vault_path = None
 255        self._vault = None
 256        self.reset()
 257        self._history(history_mode)
 258        self.path(db_path)
 259        self.load()
 260
 261    def path(self, path: str = None) -> str:
 262        """
 263        Set or get the path to the database file.
 264
 265        If no path is provided, the current path is returned.
 266        If a path is provided, it is set as the new path.
 267        The function also creates the necessary directories if the provided path is a file.
 268
 269        Parameters:
 270        path (str): The new path to the database file. If not provided, the current path is returned.
 271
 272        Returns:
 273        str: The current or new path to the database file.
 274        """
 275        if path is None:
 276            return self._vault_path
 277        self._vault_path = Path(path).resolve()
 278        base_path = Path(path).resolve()
 279        if base_path.is_file() or base_path.suffix:
 280            base_path = base_path.parent
 281        base_path.mkdir(parents=True, exist_ok=True)
 282        self._base_path = base_path
 283        return str(self._vault_path)
 284
 285    def base_path(self, *args) -> str:
 286        """
 287        Generate a base path by joining the provided arguments with the existing base path.
 288
 289        Parameters:
 290        *args (str): Variable length argument list of strings to be joined with the base path.
 291
 292        Returns:
 293        str: The generated base path. If no arguments are provided, the existing base path is returned.
 294        """
 295        if not args:
 296            return str(self._base_path)
 297        filtered_args = []
 298        ignored_filename = None
 299        for arg in args:
 300            if Path(arg).suffix:
 301                ignored_filename = arg
 302            else:
 303                filtered_args.append(arg)
 304        base_path = Path(self._base_path)
 305        full_path = base_path.joinpath(*filtered_args)
 306        full_path.mkdir(parents=True, exist_ok=True)
 307        if ignored_filename is not None:
 308            return full_path.resolve() / ignored_filename  # Join with the ignored filename
 309        return str(full_path.resolve())
 310
 311    @staticmethod
 312    def scale(x: float | int | Decimal, decimal_places: int = 2) -> int:
 313        """
 314        Scales a numerical value by a specified power of 10, returning an integer.
 315
 316        This function is designed to handle various numeric types (`float`, `int`, or `Decimal`) and
 317        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
 318
 319        Parameters:
 320        x: The numeric value to scale. Can be a floating-point number, integer, or decimal.
 321        decimal_places: The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
 322            by a factor of 100 (e.g., converts 1.23 to 123).
 323
 324        Returns:
 325        The scaled value, rounded to the nearest integer.
 326
 327        Raises:
 328        TypeError: If the input `x` is not a valid numeric type.
 329
 330        Examples:
 331        >>> ZakatTracker.scale(3.14159)
 332        314
 333        >>> ZakatTracker.scale(1234, decimal_places=3)
 334        1234000
 335        >>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
 336        50
 337        """
 338        if not isinstance(x, (float, int, Decimal)):
 339            raise TypeError("Input 'x' must be a float, int, or Decimal.")
 340        return int(Decimal(f"{x:.{decimal_places}f}") * (10 ** decimal_places))
 341
 342    @staticmethod
 343    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | Decimal:
 344        """
 345        Unscales an integer by a power of 10.
 346
 347        Parameters:
 348        x: The integer to unscale.
 349        return_type: The desired type for the returned value. Can be float, int, or Decimal. Defaults to float.
 350        decimal_places: The power of 10 to use. Defaults to 2.
 351
 352        Returns:
 353        The unscaled number, converted to the specified return_type.
 354
 355        Raises:
 356        TypeError: If the return_type is not float or Decimal.
 357        """
 358        if return_type not in (float, Decimal):
 359            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and Decimal.')
 360        return round(return_type(x / (10 ** decimal_places)), decimal_places)
 361
 362    def _history(self, status: bool = None) -> bool:
 363        """
 364        Enable or disable history tracking.
 365
 366        Parameters:
 367        status (bool): The status of history tracking. Default is True.
 368
 369        Returns:
 370        None
 371        """
 372        if status is not None:
 373            self._history_mode = status
 374        return self._history_mode
 375
 376    def reset(self) -> None:
 377        """
 378        Reset the internal data structure to its initial state.
 379
 380        Parameters:
 381        None
 382
 383        Returns:
 384        None
 385        """
 386        self._vault = {
 387            'account': {},
 388            'exchange': {},
 389            'history': {},
 390            'lock': None,
 391            'report': {},
 392        }
 393
 394    @staticmethod
 395    def time(now: datetime = None) -> int:
 396        """
 397        Generates a timestamp based on the provided datetime object or the current datetime.
 398
 399        Parameters:
 400        now (datetime, optional): The datetime object to generate the timestamp from.
 401        If not provided, the current datetime is used.
 402
 403        Returns:
 404        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
 405            before 1970 will return in negative until 1000AD.
 406        """
 407        if now is None:
 408            now = datetime.datetime.now()
 409        ordinal_day = now.toordinal()
 410        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
 411        return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)
 412
 413    @staticmethod
 414    def time_to_datetime(ordinal_ns: int) -> datetime:
 415        """
 416        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
 417
 418        Parameters:
 419        ordinal_ns (int): The ordinal number of days since 1000-01-01.
 420
 421        Returns:
 422        datetime: The corresponding datetime object.
 423        """
 424        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
 425        ns_in_day = ordinal_ns % 86_400_000_000_000
 426        d = datetime.datetime.fromordinal(ordinal_day)
 427        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
 428        return datetime.datetime.combine(d, datetime.time()) + t
 429
 430    def clean_history(self, lock: int | None = None) -> int:
 431        """
 432        Cleans up the history of actions performed on the ZakatTracker instance.
 433
 434        Parameters:
 435        lock (int, optional): The lock ID is used to clean up the empty history.
 436            If not provided, it cleans up the empty history records for all locks.
 437
 438        Returns:
 439        int: The number of locks cleaned up.
 440        """
 441        count = 0
 442        if lock in self._vault['history']:
 443            if len(self._vault['history'][lock]) <= 0:
 444                count += 1
 445                del self._vault['history'][lock]
 446            return count
 447        self.free(self.lock())
 448        for lock in self._vault['history']:
 449            if len(self._vault['history'][lock]) <= 0:
 450                count += 1
 451                del self._vault['history'][lock]
 452        return count
 453
 454    def _step(self, action: Action = None, account=None, ref: int = None, file: int = None, value: float = None,
 455              key: str = None, math_operation: MathOperation = None) -> int:
 456        """
 457        This method is responsible for recording the actions performed on the ZakatTracker.
 458
 459        Parameters:
 460        - action (Action): The type of action performed.
 461        - account (str): The account number on which the action was performed.
 462        - ref (int): The reference number of the action.
 463        - file (int): The file reference number of the action.
 464        - value (int): The value associated with the action.
 465        - key (str): The key associated with the action.
 466        - math_operation (MathOperation): The mathematical operation performed during the action.
 467
 468        Returns:
 469        - int: The lock time of the recorded action. If no lock was performed, it returns 0.
 470        """
 471        if not self._history():
 472            return 0
 473        lock = self._vault['lock']
 474        if self.nolock():
 475            lock = self._vault['lock'] = self.time()
 476            self._vault['history'][lock] = []
 477        if action is None:
 478            return lock
 479        self._vault['history'][lock].append({
 480            'action': action,
 481            'account': account,
 482            'ref': ref,
 483            'file': file,
 484            'key': key,
 485            'value': value,
 486            'math': math_operation,
 487        })
 488        return lock
 489
 490    def nolock(self) -> bool:
 491        """
 492        Check if the vault lock is currently not set.
 493
 494        Returns:
 495        bool: True if the vault lock is not set, False otherwise.
 496        """
 497        return self._vault['lock'] is None
 498
 499    def lock(self) -> int:
 500        """
 501        Acquires a lock on the ZakatTracker instance.
 502
 503        Returns:
 504        int: The lock ID. This ID can be used to release the lock later.
 505        """
 506        return self._step()
 507
 508    def vault(self) -> dict:
 509        """
 510        Returns a copy of the internal vault dictionary.
 511
 512        This method is used to retrieve the current state of the ZakatTracker object.
 513        It provides a snapshot of the internal data structure, allowing for further
 514        processing or analysis.
 515
 516        Returns:
 517        dict: A copy of the internal vault dictionary.
 518        """
 519        return self._vault.copy()
 520
 521    def stats(self) -> dict[str, tuple]:
 522        """
 523        Calculates and returns statistics about the object's data storage.
 524
 525        This method determines the size of the database file on disk and the
 526        size of the data currently held in RAM (likely within a dictionary).
 527        Both sizes are reported in bytes and in a human-readable format
 528        (e.g., KB, MB).
 529
 530        Returns:
 531        dict[str, tuple]: A dictionary containing the following statistics:
 532
 533            * 'database': A tuple with two elements:
 534                - The database file size in bytes (int).
 535                - The database file size in human-readable format (str).
 536            * 'ram': A tuple with two elements:
 537                - The RAM usage (dictionary size) in bytes (int).
 538                - The RAM usage in human-readable format (str).
 539
 540        Example:
 541        >>> stats = my_object.stats()
 542        >>> print(stats['database'])
 543        (256000, '250.0 KB')
 544        >>> print(stats['ram'])
 545        (12345, '12.1 KB')
 546        """
 547        ram_size = self.get_dict_size(self.vault())
 548        file_size = os.path.getsize(self.path())
 549        return {
 550            'database': (file_size, self.human_readable_size(file_size)),
 551            'ram': (ram_size, self.human_readable_size(ram_size)),
 552        }
 553
 554    def files(self) -> list[dict[str, str | int]]:
 555        """
 556        Retrieves information about files associated with this class.
 557
 558        This class method provides a standardized way to gather details about
 559        files used by the class for storage, snapshots, and CSV imports.
 560
 561        Returns:
 562        list[dict[str, str | int]]: A list of dictionaries, each containing information
 563            about a specific file:
 564
 565            * type (str): The type of file ('database', 'snapshot', 'import_csv').
 566            * path (str): The full file path.
 567            * exists (bool): Whether the file exists on the filesystem.
 568            * size (int): The file size in bytes (0 if the file doesn't exist).
 569            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
 570
 571        Example:
 572        ```
 573        file_info = MyClass.files()
 574        for info in file_info:
 575            print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
 576        ```
 577        """
 578        result = []
 579        for file_type, path in {
 580            'database': self.path(),
 581            'snapshot': self.snapshot_cache_path(),
 582            'import_csv': self.import_csv_cache_path(),
 583        }.items():
 584            exists = os.path.exists(path)
 585            size = os.path.getsize(path) if exists else 0
 586            human_readable_size = self.human_readable_size(size) if exists else 0
 587            result.append({
 588                'type': file_type,
 589                'path': path,
 590                'exists': exists,
 591                'size': size,
 592                'human_readable_size': human_readable_size,
 593            })
 594        return result
 595
 596    def steps(self) -> dict:
 597        """
 598        Returns a copy of the history of steps taken in the ZakatTracker.
 599
 600        The history is a dictionary where each key is a unique identifier for a step,
 601        and the corresponding value is a dictionary containing information about the step.
 602
 603        Returns:
 604        dict: A copy of the history of steps taken in the ZakatTracker.
 605        """
 606        return self._vault['history'].copy()
 607
 608    def free(self, lock: int, auto_save: bool = True) -> bool:
 609        """
 610        Releases the lock on the database.
 611
 612        Parameters:
 613        lock (int): The lock ID to be released.
 614        auto_save (bool): Whether to automatically save the database after releasing the lock.
 615
 616        Returns:
 617        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
 618        """
 619        if lock == self._vault['lock']:
 620            self._vault['lock'] = None
 621            self.clean_history(lock)
 622            if auto_save:
 623                return self.save(self.path())
 624            return True
 625        return False
 626
 627    def account_exists(self, account) -> bool:
 628        """
 629        Check if the given account exists in the vault.
 630
 631        Parameters:
 632        account (str): The account number to check.
 633
 634        Returns:
 635        bool: True if the account exists, False otherwise.
 636        """
 637        return account in self._vault['account']
 638
 639    def box_size(self, account) -> int:
 640        """
 641        Calculate the size of the box for a specific account.
 642
 643        Parameters:
 644        account (str): The account number for which the box size needs to be calculated.
 645
 646        Returns:
 647        int: The size of the box for the given account. If the account does not exist, -1 is returned.
 648        """
 649        if self.account_exists(account):
 650            return len(self._vault['account'][account]['box'])
 651        return -1
 652
 653    def log_size(self, account) -> int:
 654        """
 655        Get the size of the log for a specific account.
 656
 657        Parameters:
 658        account (str): The account number for which the log size needs to be calculated.
 659
 660        Returns:
 661        int: The size of the log for the given account. If the account does not exist, -1 is returned.
 662        """
 663        if self.account_exists(account):
 664            return len(self._vault['account'][account]['log'])
 665        return -1
 666
 667    @staticmethod
 668    def file_hash(file_path: str, algorithm: str = "blake2b") -> str:
 669        """
 670        Calculates the hash of a file using the specified algorithm.
 671
 672        Parameters:
 673        file_path (str): The path to the file.
 674        algorithm (str, optional): The hashing algorithm to use. Defaults to "blake2b".
 675
 676        Returns:
 677        str: The hexadecimal representation of the file's hash.
 678        """
 679        hash_obj = hashlib.new(algorithm)  # Create the hash object
 680        with open(file_path, "rb") as f:  # Open file in binary mode for reading
 681            for chunk in iter(lambda: f.read(4096), b""):  # Read file in chunks
 682                hash_obj.update(chunk)
 683        return hash_obj.hexdigest()  # Return the hash as a hexadecimal string
 684
 685    def snapshot_cache_path(self):
 686        """
 687        Generate the path for the cache file used to store snapshots.
 688
 689        The cache file is a pickle file that stores the timestamps of the snapshots.
 690        The file name is derived from the main database file name by replacing the ".pickle" extension with ".snapshots.pickle".
 691
 692        Returns:
 693        str: The path to the cache file.
 694        """
 695        path = str(self.path())
 696        if path.endswith(".pickle"):
 697            path = path[:-7]
 698        return path + '.snapshots.pickle'
 699
 700    def snapshot(self) -> bool:
 701        """
 702        This function creates a snapshot of the current database state.
 703
 704        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
 705        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
 706        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
 707        in a new pickle file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp.
 708
 709        Parameters:
 710        None
 711
 712        Returns:
 713        bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
 714        """
 715        current_hash = self.file_hash(self.path())
 716        cache: dict[str, int] = {}  # hash: time_ns
 717        try:
 718            with open(self.snapshot_cache_path(), "rb") as f:
 719                cache = pickle.load(f)
 720        except:
 721            pass
 722        if current_hash in cache:
 723            return True
 724        time = time_ns()
 725        cache[current_hash] = time
 726        if not self.save(self.base_path('snapshots', f'{time}.pickle')):
 727            return False
 728        with open(self.snapshot_cache_path(), "wb") as f:
 729            pickle.dump(cache, f)
 730        return True
 731
 732    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
 733            -> dict[int, tuple[str, str, bool]]:
 734        """
 735        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
 736
 737        Parameters:
 738        - hide_missing (bool): If True, only include snapshots that exist in the dictionary. Default is True.
 739        - verified_hash_only (bool): If True, only include snapshots with a valid hash. Default is False.
 740
 741        Returns:
 742        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
 743        and the values are tuples containing the snapshot's hash, path, and existence status.
 744        """
 745        cache: dict[str, int] = {}  # hash: time_ns
 746        try:
 747            with open(self.snapshot_cache_path(), "rb") as f:
 748                cache = pickle.load(f)
 749        except:
 750            pass
 751        if not cache:
 752            return {}
 753        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
 754        for file_hash, ref in cache.items():
 755            path = self.base_path('snapshots', f'{ref}.pickle')
 756            exists = os.path.exists(path)
 757            valid_hash = self.file_hash(path) == file_hash if verified_hash_only else True
 758            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
 759                continue
 760            if exists or not hide_missing:
 761                result[ref] = (file_hash, path, exists)
 762        return result
 763
 764    def recall(self, dry=True, debug=False) -> bool:
 765        """
 766        Revert the last operation.
 767
 768        Parameters:
 769        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
 770        debug (bool): If True, the function will print debug information. Default is False.
 771
 772        Returns:
 773        bool: True if the operation was successful, False otherwise.
 774        """
 775        if not self.nolock() or len(self._vault['history']) == 0:
 776            return False
 777        if len(self._vault['history']) <= 0:
 778            return False
 779        ref = sorted(self._vault['history'].keys())[-1]
 780        if debug:
 781            print('recall', ref)
 782        memory = self._vault['history'][ref]
 783        if debug:
 784            print(type(memory), 'memory', memory)
 785        limit = len(memory) + 1
 786        sub_positive_log_negative = 0
 787        for i in range(-1, -limit, -1):
 788            x = memory[i]
 789            if debug:
 790                print(type(x), x)
 791            match x['action']:
 792                case Action.CREATE:
 793                    if x['account'] is not None:
 794                        if self.account_exists(x['account']):
 795                            if debug:
 796                                print('account', self._vault['account'][x['account']])
 797                            assert len(self._vault['account'][x['account']]['box']) == 0
 798                            assert self._vault['account'][x['account']]['balance'] == 0
 799                            assert self._vault['account'][x['account']]['count'] == 0
 800                            if dry:
 801                                continue
 802                            del self._vault['account'][x['account']]
 803
 804                case Action.TRACK:
 805                    if x['account'] is not None:
 806                        if self.account_exists(x['account']):
 807                            if dry:
 808                                continue
 809                            self._vault['account'][x['account']]['balance'] -= x['value']
 810                            self._vault['account'][x['account']]['count'] -= 1
 811                            del self._vault['account'][x['account']]['box'][x['ref']]
 812
 813                case Action.LOG:
 814                    if x['account'] is not None:
 815                        if self.account_exists(x['account']):
 816                            if x['ref'] in self._vault['account'][x['account']]['log']:
 817                                if dry:
 818                                    continue
 819                                if sub_positive_log_negative == -x['value']:
 820                                    self._vault['account'][x['account']]['count'] -= 1
 821                                    sub_positive_log_negative = 0
 822                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
 823                                if not box_ref is None:
 824                                    assert self.box_exists(x['account'], box_ref)
 825                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
 826                                    assert box_value < 0
 827
 828                                    try:
 829                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
 830                                    except TypeError:
 831                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += Decimal(
 832                                            -box_value)
 833
 834                                    try:
 835                                        self._vault['account'][x['account']]['balance'] += -box_value
 836                                    except TypeError:
 837                                        self._vault['account'][x['account']]['balance'] += Decimal(-box_value)
 838
 839                                    self._vault['account'][x['account']]['count'] -= 1
 840                                del self._vault['account'][x['account']]['log'][x['ref']]
 841
 842                case Action.SUB:
 843                    if x['account'] is not None:
 844                        if self.account_exists(x['account']):
 845                            if x['ref'] in self._vault['account'][x['account']]['box']:
 846                                if dry:
 847                                    continue
 848                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
 849                                self._vault['account'][x['account']]['balance'] += x['value']
 850                                sub_positive_log_negative = x['value']
 851
 852                case Action.ADD_FILE:
 853                    if x['account'] is not None:
 854                        if self.account_exists(x['account']):
 855                            if x['ref'] in self._vault['account'][x['account']]['log']:
 856                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
 857                                    if dry:
 858                                        continue
 859                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
 860
 861                case Action.REMOVE_FILE:
 862                    if x['account'] is not None:
 863                        if self.account_exists(x['account']):
 864                            if x['ref'] in self._vault['account'][x['account']]['log']:
 865                                if dry:
 866                                    continue
 867                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
 868
 869                case Action.BOX_TRANSFER:
 870                    if x['account'] is not None:
 871                        if self.account_exists(x['account']):
 872                            if x['ref'] in self._vault['account'][x['account']]['box']:
 873                                if dry:
 874                                    continue
 875                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
 876
 877                case Action.EXCHANGE:
 878                    if x['account'] is not None:
 879                        if x['account'] in self._vault['exchange']:
 880                            if x['ref'] in self._vault['exchange'][x['account']]:
 881                                if dry:
 882                                    continue
 883                                del self._vault['exchange'][x['account']][x['ref']]
 884
 885                case Action.REPORT:
 886                    if x['ref'] in self._vault['report']:
 887                        if dry:
 888                            continue
 889                        del self._vault['report'][x['ref']]
 890
 891                case Action.ZAKAT:
 892                    if x['account'] is not None:
 893                        if self.account_exists(x['account']):
 894                            if x['ref'] in self._vault['account'][x['account']]['box']:
 895                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
 896                                    if dry:
 897                                        continue
 898                                    match x['math']:
 899                                        case MathOperation.ADDITION:
 900                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
 901                                                'value']
 902                                        case MathOperation.EQUAL:
 903                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
 904                                        case MathOperation.SUBTRACTION:
 905                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
 906                                                'value']
 907
 908        if not dry:
 909            del self._vault['history'][ref]
 910        return True
 911
 912    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
 913        """
 914        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
 915
 916        Parameters:
 917        account (str): The account number for which to check the existence of the reference.
 918        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
 919        ref (int): The reference (transaction) number to check for existence.
 920
 921        Returns:
 922        bool: True if the reference exists for the given account and reference type, False otherwise.
 923        """
 924        if account in self._vault['account']:
 925            return ref in self._vault['account'][account][ref_type]
 926        return False
 927
 928    def box_exists(self, account: str, ref: int) -> bool:
 929        """
 930        Check if a specific box (transaction) exists in the vault for a given account and reference.
 931
 932        Parameters:
 933        - account (str): The account number for which to check the existence of the box.
 934        - ref (int): The reference (transaction) number to check for existence.
 935
 936        Returns:
 937        - bool: True if the box exists for the given account and reference, False otherwise.
 938        """
 939        return self.ref_exists(account, 'box', ref)
 940
 941    def track(self, unscaled_value: float | int | Decimal = 0, desc: str = '', account: str = 1, logging: bool = True,
 942              created: int = None,
 943              debug: bool = False) -> int:
 944        """
 945        This function tracks a transaction for a specific account.
 946
 947        Parameters:
 948        unscaled_value (float | int | Decimal): The value of the transaction. Default is 0.
 949        desc (str): The description of the transaction. Default is an empty string.
 950        account (str): The account for which the transaction is being tracked. Default is '1'.
 951        logging (bool): Whether to log the transaction. Default is True.
 952        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
 953        debug (bool): Whether to print debug information. Default is False.
 954
 955        Returns:
 956        int: The timestamp of the transaction.
 957
 958        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.
 959
 960        Raises:
 961        ValueError: The log transaction happened again in the same nanosecond time.
 962        ValueError: The box transaction happened again in the same nanosecond time.
 963        """
 964        if debug:
 965            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
 966        if created is None:
 967            created = self.time()
 968        no_lock = self.nolock()
 969        self.lock()
 970        if not self.account_exists(account):
 971            if debug:
 972                print(f"account {account} created")
 973            self._vault['account'][account] = {
 974                'balance': 0,
 975                'box': {},
 976                'count': 0,
 977                'log': {},
 978                'hide': False,
 979                'zakatable': True,
 980            }
 981            self._step(Action.CREATE, account)
 982        if unscaled_value == 0:
 983            if no_lock:
 984                self.free(self.lock())
 985            return 0
 986        value = self.scale(unscaled_value)
 987        if logging:
 988            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
 989        if debug:
 990            print('create-box', created)
 991        if self.box_exists(account, created):
 992            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
 993        if debug:
 994            print('created-box', created)
 995        self._vault['account'][account]['box'][created] = {
 996            'capital': value,
 997            'count': 0,
 998            'last': 0,
 999            'rest': value,
1000            'total': 0,
1001        }
1002        self._step(Action.TRACK, account, ref=created, value=value)
1003        if no_lock:
1004            self.free(self.lock())
1005        return created
1006
1007    def log_exists(self, account: str, ref: int) -> bool:
1008        """
1009        Checks if a specific transaction log entry exists for a given account.
1010
1011        Parameters:
1012        account (str): The account number associated with the transaction log.
1013        ref (int): The reference to the transaction log entry.
1014
1015        Returns:
1016        bool: True if the transaction log entry exists, False otherwise.
1017        """
1018        return self.ref_exists(account, 'log', ref)
1019
1020    def _log(self, value: float, desc: str = '', account: str = 1, created: int = None, ref: int = None,
1021             debug: bool = False) -> int:
1022        """
1023        Log a transaction into the account's log.
1024
1025        Parameters:
1026        value (float): The value of the transaction.
1027        desc (str): The description of the transaction.
1028        account (str): The account to log the transaction into. Default is '1'.
1029        created (int): The timestamp of the transaction. If not provided, it will be generated.
1030
1031        Returns:
1032        int: The timestamp of the logged transaction.
1033
1034        This method updates the account's balance, count, and log with the transaction details.
1035        It also creates a step in the history of the transaction.
1036
1037        Raises:
1038        ValueError: The log transaction happened again in the same nanosecond time.
1039        """
1040        if debug:
1041            print('_log', f'debug={debug}')
1042        if created is None:
1043            created = self.time()
1044        try:
1045            self._vault['account'][account]['balance'] += value
1046        except TypeError:
1047            self._vault['account'][account]['balance'] += Decimal(value)
1048        self._vault['account'][account]['count'] += 1
1049        if debug:
1050            print('create-log', created)
1051        if self.log_exists(account, created):
1052            raise ValueError(f"The log transaction happened again in the same nanosecond time({created}).")
1053        if debug:
1054            print('created-log', created)
1055        self._vault['account'][account]['log'][created] = {
1056            'value': value,
1057            'desc': desc,
1058            'ref': ref,
1059            'file': {},
1060        }
1061        self._step(Action.LOG, account, ref=created, value=value)
1062        return created
1063
1064    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
1065                 debug: bool = False) -> dict:
1066        """
1067        This method is used to record or retrieve exchange rates for a specific account.
1068
1069        Parameters:
1070        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1071        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1072        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1073        - description (str): A description of the exchange rate.
1074
1075        Returns:
1076        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1077        it returns a dictionary with default values for the rate and description.
1078        """
1079        if debug:
1080            print('exchange', f'debug={debug}')
1081        if created is None:
1082            created = self.time()
1083        no_lock = self.nolock()
1084        self.lock()
1085        if rate is not None:
1086            if rate <= 0:
1087                return dict()
1088            if account not in self._vault['exchange']:
1089                self._vault['exchange'][account] = {}
1090            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1091                return {"time": created, "rate": 1, "description": None}
1092            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
1093            self._step(Action.EXCHANGE, account, ref=created, value=rate)
1094            if no_lock:
1095                self.free(self.lock())
1096            if debug:
1097                print("exchange-created-1",
1098                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1099
1100        if account in self._vault['exchange']:
1101            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
1102            if valid_rates:
1103                latest_rate = max(valid_rates, key=lambda x: x[0])
1104                if debug:
1105                    print("exchange-read-1",
1106                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
1107                          'latest_rate', latest_rate)
1108                result = latest_rate[1]
1109                result['time'] = latest_rate[0]
1110                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1111        if debug:
1112            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1113        return {"time": created, "rate": 1, "description": None}  # إرجاع القيمة الافتراضية مع وصف فارغ
1114
1115    @staticmethod
1116    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1117        """
1118        This function calculates the exchanged amount of a currency.
1119
1120        Args:
1121            x (float): The original amount of the currency.
1122            x_rate (float): The exchange rate of the original currency.
1123            y_rate (float): The exchange rate of the target currency.
1124
1125        Returns:
1126            float: The exchanged amount of the target currency.
1127        """
1128        return (x * x_rate) / y_rate
1129
1130    def exchanges(self) -> dict:
1131        """
1132        Retrieve the recorded exchange rates for all accounts.
1133
1134        Parameters:
1135        None
1136
1137        Returns:
1138        dict: A dictionary containing all recorded exchange rates.
1139        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1140        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1141        """
1142        return self._vault['exchange'].copy()
1143
1144    def accounts(self) -> dict:
1145        """
1146        Returns a dictionary containing account numbers as keys and their respective balances as values.
1147
1148        Parameters:
1149        None
1150
1151        Returns:
1152        dict: A dictionary where keys are account numbers and values are their respective balances.
1153        """
1154        result = {}
1155        for i in self._vault['account']:
1156            result[i] = self._vault['account'][i]['balance']
1157        return result
1158
1159    def boxes(self, account) -> dict:
1160        """
1161        Retrieve the boxes (transactions) associated with a specific account.
1162
1163        Parameters:
1164        account (str): The account number for which to retrieve the boxes.
1165
1166        Returns:
1167        dict: A dictionary containing the boxes associated with the given account.
1168        If the account does not exist, an empty dictionary is returned.
1169        """
1170        if self.account_exists(account):
1171            return self._vault['account'][account]['box']
1172        return {}
1173
1174    def logs(self, account) -> dict:
1175        """
1176        Retrieve the logs (transactions) associated with a specific account.
1177
1178        Parameters:
1179        account (str): The account number for which to retrieve the logs.
1180
1181        Returns:
1182        dict: A dictionary containing the logs associated with the given account.
1183        If the account does not exist, an empty dictionary is returned.
1184        """
1185        if self.account_exists(account):
1186            return self._vault['account'][account]['log']
1187        return {}
1188
1189    def add_file(self, account: str, ref: int, path: str) -> int:
1190        """
1191        Adds a file reference to a specific transaction log entry in the vault.
1192
1193        Parameters:
1194        account (str): The account number associated with the transaction log.
1195        ref (int): The reference to the transaction log entry.
1196        path (str): The path of the file to be added.
1197
1198        Returns:
1199        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1200        """
1201        if self.account_exists(account):
1202            if ref in self._vault['account'][account]['log']:
1203                file_ref = self.time()
1204                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1205                no_lock = self.nolock()
1206                self.lock()
1207                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1208                if no_lock:
1209                    self.free(self.lock())
1210                return file_ref
1211        return 0
1212
1213    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1214        """
1215        Removes a file reference from a specific transaction log entry in the vault.
1216
1217        Parameters:
1218        account (str): The account number associated with the transaction log.
1219        ref (int): The reference to the transaction log entry.
1220        file_ref (int): The reference of the file to be removed.
1221
1222        Returns:
1223        bool: True if the file reference is successfully removed, False otherwise.
1224        """
1225        if self.account_exists(account):
1226            if ref in self._vault['account'][account]['log']:
1227                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1228                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1229                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1230                    no_lock = self.nolock()
1231                    self.lock()
1232                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1233                    if no_lock:
1234                        self.free(self.lock())
1235                    return True
1236        return False
1237
1238    def balance(self, account: str = 1, cached: bool = True) -> int:
1239        """
1240        Calculate and return the balance of a specific account.
1241
1242        Parameters:
1243        account (str): The account number. Default is '1'.
1244        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1245
1246        Returns:
1247        int: The balance of the account.
1248
1249        Note:
1250        If cached is True, the function returns the cached balance.
1251        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1252        """
1253        if cached:
1254            return self._vault['account'][account]['balance']
1255        x = 0
1256        return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1]
1257
1258    def hide(self, account, status: bool = None) -> bool:
1259        """
1260        Check or set the hide status of a specific account.
1261
1262        Parameters:
1263        account (str): The account number.
1264        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1265
1266        Returns:
1267        bool: The current or updated hide status of the account.
1268
1269        Raises:
1270        None
1271
1272        Example:
1273        >>> tracker = ZakatTracker()
1274        >>> ref = tracker.track(51, 'desc', 'account1')
1275        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1276        False
1277        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1278        True
1279        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1280        True
1281        >>> tracker.hide('account1', False)
1282        False
1283        """
1284        if self.account_exists(account):
1285            if status is None:
1286                return self._vault['account'][account]['hide']
1287            self._vault['account'][account]['hide'] = status
1288            return status
1289        return False
1290
1291    def zakatable(self, account, status: bool = None) -> bool:
1292        """
1293        Check or set the zakatable status of a specific account.
1294
1295        Parameters:
1296        account (str): The account number.
1297        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1298
1299        Returns:
1300        bool: The current or updated zakatable status of the account.
1301
1302        Raises:
1303        None
1304
1305        Example:
1306        >>> tracker = ZakatTracker()
1307        >>> ref = tracker.track(51, 'desc', 'account1')
1308        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1309        True
1310        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1311        True
1312        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1313        True
1314        >>> tracker.zakatable('account1', False)
1315        False
1316        """
1317        if self.account_exists(account):
1318            if status is None:
1319                return self._vault['account'][account]['zakatable']
1320            self._vault['account'][account]['zakatable'] = status
1321            return status
1322        return False
1323
1324    def sub(self, unscaled_value: float | int | Decimal, desc: str = '', account: str = 1, created: int = None,
1325            debug: bool = False) \
1326            -> tuple[
1327                int,
1328                list[
1329                    tuple[int, int],
1330                ],
1331            ] | tuple:
1332        """
1333        Subtracts a specified value from an account's balance.
1334
1335        Parameters:
1336        unscaled_value (float | int | Decimal): The amount to be subtracted.
1337        desc (str): A description for the transaction. Defaults to an empty string.
1338        account (str): The account from which the value will be subtracted. Defaults to '1'.
1339        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1340        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1341
1342        Returns:
1343        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1344
1345        If the amount to subtract is greater than the account's balance,
1346        the remaining amount will be transferred to a new transaction with a negative value.
1347
1348        Raises:
1349        ValueError: The box transaction happened again in the same nanosecond time.
1350        ValueError: The log transaction happened again in the same nanosecond time.
1351        """
1352        if debug:
1353            print('sub', f'debug={debug}')
1354        if unscaled_value < 0:
1355            return tuple()
1356        if unscaled_value == 0:
1357            ref = self.track(unscaled_value, '', account)
1358            return ref, ref
1359        if created is None:
1360            created = self.time()
1361        no_lock = self.nolock()
1362        self.lock()
1363        self.track(0, '', account)
1364        value = self.scale(unscaled_value)
1365        self._log(value=-value, desc=desc, account=account, created=created, ref=None, debug=debug)
1366        ids = sorted(self._vault['account'][account]['box'].keys())
1367        limit = len(ids) + 1
1368        target = value
1369        if debug:
1370            print('ids', ids)
1371        ages = []
1372        for i in range(-1, -limit, -1):
1373            if target == 0:
1374                break
1375            j = ids[i]
1376            if debug:
1377                print('i', i, 'j', j)
1378            rest = self._vault['account'][account]['box'][j]['rest']
1379            if rest >= target:
1380                self._vault['account'][account]['box'][j]['rest'] -= target
1381                self._step(Action.SUB, account, ref=j, value=target)
1382                ages.append((j, target))
1383                target = 0
1384                break
1385            elif target > rest > 0:
1386                chunk = rest
1387                target -= chunk
1388                self._step(Action.SUB, account, ref=j, value=chunk)
1389                ages.append((j, chunk))
1390                self._vault['account'][account]['box'][j]['rest'] = 0
1391        if target > 0:
1392            self.track(
1393                unscaled_value=self.unscale(-target),
1394                desc=desc,
1395                account=account,
1396                logging=False,
1397                created=created,
1398            )
1399            ages.append((created, target))
1400        if no_lock:
1401            self.free(self.lock())
1402        return created, ages
1403
1404    def transfer(self, unscaled_amount: float | int | Decimal, from_account: str, to_account: str, desc: str = '',
1405                 created: int = None,
1406                 debug: bool = False) -> list[int]:
1407        """
1408        Transfers a specified value from one account to another.
1409
1410        Parameters:
1411        unscaled_amount (float | int | Decimal): The amount to be transferred.
1412        from_account (str): The account from which the value will be transferred.
1413        to_account (str): The account to which the value will be transferred.
1414        desc (str, optional): A description for the transaction. Defaults to an empty string.
1415        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1416        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1417
1418        Returns:
1419        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1420
1421        Raises:
1422        ValueError: Transfer to the same account is forbidden.
1423        ValueError: The box transaction happened again in the same nanosecond time.
1424        ValueError: The log transaction happened again in the same nanosecond time.
1425        """
1426        if debug:
1427            print('transfer', f'debug={debug}')
1428        if from_account == to_account:
1429            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1430        if unscaled_amount <= 0:
1431            return []
1432        if created is None:
1433            created = self.time()
1434        (_, ages) = self.sub(unscaled_amount, desc, from_account, created, debug=debug)
1435        times = []
1436        source_exchange = self.exchange(from_account, created)
1437        target_exchange = self.exchange(to_account, created)
1438
1439        if debug:
1440            print('ages', ages)
1441
1442        for age, value in ages:
1443            target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']))
1444            if debug:
1445                print('target_amount', target_amount)
1446            # Perform the transfer
1447            if self.box_exists(to_account, age):
1448                if debug:
1449                    print('box_exists', age)
1450                capital = self._vault['account'][to_account]['box'][age]['capital']
1451                rest = self._vault['account'][to_account]['box'][age]['rest']
1452                if debug:
1453                    print(
1454                        f"Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1455                selected_age = age
1456                if rest + target_amount > capital:
1457                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1458                    selected_age = ZakatTracker.time()
1459                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1460                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1461                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1462                              created=None, ref=None, debug=debug)
1463                times.append((age, y))
1464                continue
1465            if debug:
1466                print(
1467                    f"Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1468            y = self.track(
1469                unscaled_value=self.unscale(int(target_amount)),
1470                desc=desc,
1471                account=to_account,
1472                logging=True,
1473                created=age,
1474                debug=debug,
1475            )
1476            times.append(y)
1477        return times
1478
1479    def check(self, silver_gram_price: float, unscaled_nisab: float | int | Decimal = None, debug: bool = False, now: int = None,
1480              cycle: float = None) -> tuple:
1481        """
1482        Check the eligibility for Zakat based on the given parameters.
1483
1484        Parameters:
1485        silver_gram_price (float): The price of a gram of silver.
1486        unscaled_nisab (float | int | Decimal): The minimum amount of wealth required for Zakat. If not provided,
1487                        it will be calculated based on the silver_gram_price.
1488        debug (bool): Flag to enable debug mode.
1489        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1490        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1491
1492        Returns:
1493        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1494        and a dictionary containing the Zakat plan.
1495        """
1496        if debug:
1497            print('check', f'debug={debug}')
1498        if now is None:
1499            now = self.time()
1500        if cycle is None:
1501            cycle = ZakatTracker.TimeCycle()
1502        if unscaled_nisab is None:
1503            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
1504        nisab = self.scale(unscaled_nisab)
1505        plan = {}
1506        below_nisab = 0
1507        brief = [0, 0, 0]
1508        valid = False
1509        if debug:
1510            print('exchanges', self.exchanges())
1511        for x in self._vault['account']:
1512            if not self.zakatable(x):
1513                continue
1514            _box = self._vault['account'][x]['box']
1515            _log = self._vault['account'][x]['log']
1516            limit = len(_box) + 1
1517            ids = sorted(self._vault['account'][x]['box'].keys())
1518            for i in range(-1, -limit, -1):
1519                j = ids[i]
1520                rest = float(_box[j]['rest'])
1521                if rest <= 0:
1522                    continue
1523                exchange = self.exchange(x, created=self.time())
1524                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1525                brief[0] += rest
1526                index = limit + i - 1
1527                epoch = (now - j) / cycle
1528                if debug:
1529                    print(f"Epoch: {epoch}", _box[j])
1530                if _box[j]['last'] > 0:
1531                    epoch = (now - _box[j]['last']) / cycle
1532                if debug:
1533                    print(f"Epoch: {epoch}")
1534                epoch = floor(epoch)
1535                if debug:
1536                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1537                if epoch == 0:
1538                    continue
1539                if debug:
1540                    print("Epoch - PASSED")
1541                brief[1] += rest
1542                if rest >= nisab:
1543                    total = 0
1544                    for _ in range(epoch):
1545                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1546                    if total > 0:
1547                        if x not in plan:
1548                            plan[x] = {}
1549                        valid = True
1550                        brief[2] += total
1551                        plan[x][index] = {
1552                            'total': total,
1553                            'count': epoch,
1554                            'box_time': j,
1555                            'box_capital': _box[j]['capital'],
1556                            'box_rest': _box[j]['rest'],
1557                            'box_last': _box[j]['last'],
1558                            'box_total': _box[j]['total'],
1559                            'box_count': _box[j]['count'],
1560                            'box_log': _log[j]['desc'],
1561                            'exchange_rate': exchange['rate'],
1562                            'exchange_time': exchange['time'],
1563                            'exchange_desc': exchange['description'],
1564                        }
1565                else:
1566                    chunk = ZakatTracker.ZakatCut(float(rest))
1567                    if chunk > 0:
1568                        if x not in plan:
1569                            plan[x] = {}
1570                        if j not in plan[x].keys():
1571                            plan[x][index] = {}
1572                        below_nisab += rest
1573                        brief[2] += chunk
1574                        plan[x][index]['below_nisab'] = chunk
1575                        plan[x][index]['total'] = chunk
1576                        plan[x][index]['count'] = epoch
1577                        plan[x][index]['box_time'] = j
1578                        plan[x][index]['box_capital'] = _box[j]['capital']
1579                        plan[x][index]['box_rest'] = _box[j]['rest']
1580                        plan[x][index]['box_last'] = _box[j]['last']
1581                        plan[x][index]['box_total'] = _box[j]['total']
1582                        plan[x][index]['box_count'] = _box[j]['count']
1583                        plan[x][index]['box_log'] = _log[j]['desc']
1584                        plan[x][index]['exchange_rate'] = exchange['rate']
1585                        plan[x][index]['exchange_time'] = exchange['time']
1586                        plan[x][index]['exchange_desc'] = exchange['description']
1587        valid = valid or below_nisab >= nisab
1588        if debug:
1589            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1590        return valid, brief, plan
1591
1592    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1593        """
1594        Build payment parts for the Zakat distribution.
1595
1596        Parameters:
1597        demand (float): The total demand for payment in local currency.
1598        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1599
1600        Returns:
1601        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1602        {
1603            'account': {
1604                'account_id': {'balance': float, 'rate': float, 'part': float},
1605                ...
1606            },
1607            'exceed': bool,
1608            'demand': float,
1609            'total': float,
1610        }
1611        """
1612        total = 0
1613        parts = {
1614            'account': {},
1615            'exceed': False,
1616            'demand': demand,
1617        }
1618        for x, y in self.accounts().items():
1619            if positive_only and y <= 0:
1620                continue
1621            total += float(y)
1622            exchange = self.exchange(x)
1623            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1624        parts['total'] = total
1625        return parts
1626
1627    @staticmethod
1628    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1629        """
1630        Checks the validity of payment parts.
1631
1632        Parameters:
1633        parts (dict): A dictionary containing payment parts information.
1634        debug (bool): Flag to enable debug mode.
1635
1636        Returns:
1637        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1638
1639        Error Codes:
1640        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1641        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1642        3: 'part' value in parts['account'][x] is less than 0.
1643        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1644        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1645        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1646        """
1647        if debug:
1648            print('check_payment_parts', f'debug={debug}')
1649        for i in ['demand', 'account', 'total', 'exceed']:
1650            if i not in parts:
1651                return 1
1652        exceed = parts['exceed']
1653        for x in parts['account']:
1654            for j in ['balance', 'rate', 'part']:
1655                if j not in parts['account'][x]:
1656                    return 2
1657                if parts['account'][x]['part'] < 0:
1658                    return 3
1659                if not exceed and parts['account'][x]['balance'] <= 0:
1660                    return 4
1661        demand = parts['demand']
1662        z = 0
1663        for _, y in parts['account'].items():
1664            if not exceed and y['part'] > y['balance']:
1665                return 5
1666            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1667        z = round(z, 2)
1668        demand = round(demand, 2)
1669        if debug:
1670            print('check_payment_parts', f'z = {z}, demand = {demand}')
1671            print('check_payment_parts', type(z), type(demand))
1672            print('check_payment_parts', z != demand)
1673            print('check_payment_parts', str(z) != str(demand))
1674        if z != demand and str(z) != str(demand):
1675            return 6
1676        return 0
1677
1678    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1679        """
1680        Perform Zakat calculation based on the given report and optional parts.
1681
1682        Parameters:
1683        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1684        parts (dict): A dictionary containing the payment parts for the zakat.
1685        debug (bool): A flag indicating whether to print debug information.
1686
1687        Returns:
1688        bool: True if the zakat calculation is successful, False otherwise.
1689        """
1690        if debug:
1691            print('zakat', f'debug={debug}')
1692        valid, _, plan = report
1693        if not valid:
1694            return valid
1695        parts_exist = parts is not None
1696        if parts_exist:
1697            if self.check_payment_parts(parts, debug=debug) != 0:
1698                return False
1699        if debug:
1700            print('######### zakat #######')
1701            print('parts_exist', parts_exist)
1702        no_lock = self.nolock()
1703        self.lock()
1704        report_time = self.time()
1705        self._vault['report'][report_time] = report
1706        self._step(Action.REPORT, ref=report_time)
1707        created = self.time()
1708        for x in plan:
1709            target_exchange = self.exchange(x)
1710            if debug:
1711                print(plan[x])
1712                print('-------------')
1713                print(self._vault['account'][x]['box'])
1714            ids = sorted(self._vault['account'][x]['box'].keys())
1715            if debug:
1716                print('plan[x]', plan[x])
1717            for i in plan[x].keys():
1718                j = ids[i]
1719                if debug:
1720                    print('i', i, 'j', j)
1721                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1722                           key='last',
1723                           math_operation=MathOperation.EQUAL)
1724                self._vault['account'][x]['box'][j]['last'] = created
1725                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1726                self._vault['account'][x]['box'][j]['total'] += amount
1727                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1728                           math_operation=MathOperation.ADDITION)
1729                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1730                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1731                           math_operation=MathOperation.ADDITION)
1732                if not parts_exist:
1733                    try:
1734                        self._vault['account'][x]['box'][j]['rest'] -= amount
1735                    except TypeError:
1736                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1737                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1738                    #            math_operation=MathOperation.SUBTRACTION)
1739                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1740        if parts_exist:
1741            for account, part in parts['account'].items():
1742                if part['part'] == 0:
1743                    continue
1744                if debug:
1745                    print('zakat-part', account, part['rate'])
1746                target_exchange = self.exchange(account)
1747                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1748                self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug)
1749        if no_lock:
1750            self.free(self.lock())
1751        return True
1752
1753    def export_json(self, path: str = "data.json") -> bool:
1754        """
1755        Exports the current state of the ZakatTracker object to a JSON file.
1756
1757        Parameters:
1758        path (str): The path where the JSON file will be saved. Default is "data.json".
1759
1760        Returns:
1761        bool: True if the export is successful, False otherwise.
1762
1763        Raises:
1764        No specific exceptions are raised by this method.
1765        """
1766        with open(path, "w") as file:
1767            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1768            return True
1769
1770    def save(self, path: str = None) -> bool:
1771        """
1772        Saves the ZakatTracker's current state to a pickle file.
1773
1774        This method serializes the internal data (`_vault`) along with metadata
1775        (Python version, pickle protocol) for future compatibility.
1776
1777        Parameters:
1778        path (str, optional): File path for saving. Defaults to a predefined location.
1779
1780        Returns:
1781        bool: True if the save operation is successful, False otherwise.
1782        """
1783        if path is None:
1784            path = self.path()
1785        with open(path, "wb") as f:
1786            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1787            pickle_protocol = pickle.HIGHEST_PROTOCOL
1788            data = {
1789                'python_version': version,
1790                'pickle_protocol': pickle_protocol,
1791                'data': self._vault,
1792            }
1793            pickle.dump(data, f, protocol=pickle_protocol)
1794            return True
1795
1796    def load(self, path: str = None) -> bool:
1797        """
1798        Load the current state of the ZakatTracker object from a pickle file.
1799
1800        Parameters:
1801        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1802
1803        Returns:
1804        bool: True if the load operation is successful, False otherwise.
1805        """
1806        if path is None:
1807            path = self.path()
1808        if os.path.exists(path):
1809            with open(path, "rb") as f:
1810                data = pickle.load(f)
1811                self._vault = data['data']
1812                return True
1813        return False
1814
1815    def import_csv_cache_path(self):
1816        """
1817        Generates the cache file path for imported CSV data.
1818
1819        This function constructs the file path where cached data from CSV imports
1820        will be stored. The cache file is a pickle file (.pickle extension) appended
1821        to the base path of the object.
1822
1823        Returns:
1824        str: The full path to the import CSV cache file.
1825
1826        Example:
1827            >>> obj = ZakatTracker('/data/reports')
1828            >>> obj.import_csv_cache_path()
1829            '/data/reports.import_csv.pickle'
1830        """
1831        path = str(self.path())
1832        if path.endswith(".pickle"):
1833            path = path[:-7]
1834        return path + '.import_csv.pickle'
1835
1836    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1837        """
1838        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1839
1840        Parameters:
1841        path (str): The path to the CSV file. Default is 'file.csv'.
1842        debug (bool): A flag indicating whether to print debug information.
1843
1844        Returns:
1845        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1846                and a dictionary of bad transactions.
1847
1848        Notes:
1849            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1850                                        are appropriate for the currency pairs involved in the conversions.
1851            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1852                to 1.0 or the previous rate for that account.
1853            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1854              transactions of the same account within the whole imported and existing dataset when doing `check` and
1855              `zakat` operations.
1856
1857        Example Usage:
1858            The CSV file should have the following format, rate is optional per transaction:
1859            account, desc, value, date, rate
1860            For example:
1861            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1862        """
1863        if debug:
1864            print('import_csv', f'debug={debug}')
1865        cache: list[int] = []
1866        try:
1867            with open(self.import_csv_cache_path(), "rb") as f:
1868                cache = pickle.load(f)
1869        except:
1870            pass
1871        date_formats = [
1872            "%Y-%m-%d %H:%M:%S",
1873            "%Y-%m-%dT%H:%M:%S",
1874            "%Y-%m-%dT%H%M%S",
1875            "%Y-%m-%d",
1876        ]
1877        created, found, bad = 0, 0, {}
1878        data: dict[int, list] = {}
1879        with open(path, newline='', encoding="utf-8") as f:
1880            i = 0
1881            for row in csv.reader(f, delimiter=','):
1882                i += 1
1883                hashed = hash(tuple(row))
1884                if hashed in cache:
1885                    found += 1
1886                    continue
1887                account = row[0]
1888                desc = row[1]
1889                value = float(row[2])
1890                rate = 1.0
1891                if row[4:5]:  # Empty list if index is out of range
1892                    rate = float(row[4])
1893                date: int = 0
1894                for time_format in date_formats:
1895                    try:
1896                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1897                        break
1898                    except:
1899                        pass
1900                # TODO: not allowed for negative dates in the future after enhance time functions
1901                if date == 0 or value == 0:
1902                    bad[i] = row + ('invalid date',)
1903                    continue
1904                if date not in data:
1905                    data[date] = []
1906                data[date].append((i, account, desc, value, date, rate, hashed))
1907
1908        if debug:
1909            print('import_csv', len(data))
1910
1911        if bad:
1912            return created, found, bad
1913
1914        for date, rows in sorted(data.items()):
1915            try:
1916                len_rows = len(rows)
1917                if len_rows == 1:
1918                    (_, account, desc, value, date, rate, hashed) = rows[0]
1919                    if rate > 0:
1920                        self.exchange(account, created=date, rate=rate)
1921                    if value > 0:
1922                        self.track(value, desc, account, True, date)
1923                    elif value < 0:
1924                        self.sub(-value, desc, account, date)
1925                    created += 1
1926                    cache.append(hashed)
1927                    continue
1928                if debug:
1929                    print('-- Duplicated time detected', date, 'len', len_rows)
1930                    print(rows)
1931                    print('---------------------------------')
1932                # If records are found at the same time with different accounts in the same amount
1933                # (one positive and the other negative), this indicates it is a transfer.
1934                if len_rows != 2:
1935                    raise Exception(f'more than two transactions({len_rows}) at the same time')
1936                (i, account1, desc1, value1, date1, rate1, _) = rows[0]
1937                (j, account2, desc2, value2, date2, rate2, _) = rows[1]
1938                if account1 == account2 or desc1 != desc2 or abs(value1) != abs(value2) or date1 != date2:
1939                    raise Exception('invalid transfer')
1940                if rate1 > 0:
1941                    self.exchange(account1, created=date1, rate=rate1)
1942                if rate2 > 0:
1943                    self.exchange(account2, created=date2, rate=rate2)
1944                values = {
1945                    value1: account1,
1946                    value2: account2,
1947                }
1948                self.transfer(
1949                    unscaled_amount=abs(value1),
1950                    from_account=values[min(values.keys())],
1951                    to_account=values[max(values.keys())],
1952                    desc=desc1,
1953                    created=date1,
1954                )
1955            except Exception as e:
1956                for (i, account, desc, value, date, rate, _) in rows:
1957                    bad[i] = (account, desc, value, date, rate, e)
1958                break
1959        with open(self.import_csv_cache_path(), "wb") as file:
1960            pickle.dump(cache, file)
1961        return created, found, bad
1962
1963    ########
1964    # TESTS #
1965    #######
1966
1967    @staticmethod
1968    def human_readable_size(size: float, decimal_places: int = 2) -> str:
1969        """
1970        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
1971
1972        This function iterates through progressively larger units of information
1973        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
1974        range that can be expressed with a reasonable number before the unit.
1975
1976        Parameters:
1977        size (float): The size in bytes to convert.
1978        decimal_places (int, optional): The number of decimal places to display
1979            in the result. Defaults to 2.
1980
1981        Returns:
1982        str: A string representation of the size in a human-readable format,
1983            rounded to the specified number of decimal places. For example:
1984                - "1.50 KB" (1536 bytes)
1985                - "23.00 MB" (24117248 bytes)
1986                - "1.23 GB" (1325899906 bytes)
1987        """
1988        if type(size) not in (float, int):
1989            raise TypeError("size must be a float or integer")
1990        if type(decimal_places) != int:
1991            raise TypeError("decimal_places must be an integer")
1992        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
1993            if size < 1024.0:
1994                break
1995            size /= 1024.0
1996        return f"{size:.{decimal_places}f} {unit}"
1997
1998    @staticmethod
1999    def get_dict_size(obj: dict, seen: set = None) -> float:
2000        """
2001        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
2002
2003        This function traverses the dictionary structure, accounting for the size of keys, values,
2004        and any nested objects. It handles various data types commonly found in dictionaries
2005        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
2006        of circular references.
2007
2008        Parameters:
2009        obj (dict): The dictionary whose size is to be calculated.
2010        seen (set, optional): A set used internally to track visited objects
2011                             and avoid circular references. Defaults to None.
2012
2013        Returns:
2014            float: An approximate size of the dictionary and its contents in bytes.
2015
2016        Note:
2017        - This function is a method of the `ZakatTracker` class and is likely used to
2018          estimate the memory footprint of data structures relevant to Zakat calculations.
2019        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
2020          not account for all memory overhead depending on the Python implementation.
2021        - Circular references are handled to prevent infinite recursion.
2022        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
2023        - String sizes are estimated based on character length and encoding.
2024        """
2025        size = 0
2026        if seen is None:
2027            seen = set()
2028
2029        obj_id = id(obj)
2030        if obj_id in seen:
2031            return 0
2032
2033        seen.add(obj_id)
2034        size += sys.getsizeof(obj)
2035
2036        if isinstance(obj, dict):
2037            for k, v in obj.items():
2038                size += ZakatTracker.get_dict_size(k, seen)
2039                size += ZakatTracker.get_dict_size(v, seen)
2040        elif isinstance(obj, (list, tuple, set, frozenset)):
2041            for item in obj:
2042                size += ZakatTracker.get_dict_size(item, seen)
2043        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2044            pass  # Basic numbers have a fixed size, so nothing to add here
2045        elif isinstance(obj, str):  # Handle strings
2046            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2047        return size
2048
2049    @staticmethod
2050    def duration_from_nanoseconds(ns: int,
2051                                  show_zeros_in_spoken_time: bool = False,
2052                                  spoken_time_separator=',',
2053                                  millennia: str = 'Millennia',
2054                                  century: str = 'Century',
2055                                  years: str = 'Years',
2056                                  days: str = 'Days',
2057                                  hours: str = 'Hours',
2058                                  minutes: str = 'Minutes',
2059                                  seconds: str = 'Seconds',
2060                                  milli_seconds: str = 'MilliSeconds',
2061                                  micro_seconds: str = 'MicroSeconds',
2062                                  nano_seconds: str = 'NanoSeconds',
2063                                  ) -> tuple:
2064        """
2065        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2066        Convert NanoSeconds to Human Readable Time Format.
2067        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2068        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2069        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2070        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2071
2072        INPUT : ms (AKA: MilliSeconds)
2073        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2074        OUTPUT Variables: time_lapsed, spoken_time
2075
2076        Example  Input: duration_from_nanoseconds(ns)
2077        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
2078        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')
2079        duration_from_nanoseconds(1234567890123456789012)
2080        """
2081        us, ns = divmod(ns, 1000)
2082        ms, us = divmod(us, 1000)
2083        s, ms = divmod(ms, 1000)
2084        m, s = divmod(s, 60)
2085        h, m = divmod(m, 60)
2086        d, h = divmod(h, 24)
2087        y, d = divmod(d, 365)
2088        c, y = divmod(y, 100)
2089        n, c = divmod(c, 10)
2090        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}"
2091        spoken_time_part = []
2092        if n > 0 or show_zeros_in_spoken_time:
2093            spoken_time_part.append(f"{n: 3d} {millennia}")
2094        if c > 0 or show_zeros_in_spoken_time:
2095            spoken_time_part.append(f"{c: 4d} {century}")
2096        if y > 0 or show_zeros_in_spoken_time:
2097            spoken_time_part.append(f"{y: 3d} {years}")
2098        if d > 0 or show_zeros_in_spoken_time:
2099            spoken_time_part.append(f"{d: 4d} {days}")
2100        if h > 0 or show_zeros_in_spoken_time:
2101            spoken_time_part.append(f"{h: 2d} {hours}")
2102        if m > 0 or show_zeros_in_spoken_time:
2103            spoken_time_part.append(f"{m: 2d} {minutes}")
2104        if s > 0 or show_zeros_in_spoken_time:
2105            spoken_time_part.append(f"{s: 2d} {seconds}")
2106        if ms > 0 or show_zeros_in_spoken_time:
2107            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
2108        if us > 0 or show_zeros_in_spoken_time:
2109            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
2110        if ns > 0 or show_zeros_in_spoken_time:
2111            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
2112        return time_lapsed, spoken_time_separator.join(spoken_time_part)
2113
2114    @staticmethod
2115    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2116        """
2117        Convert a specific day, month, and year into a timestamp.
2118
2119        Parameters:
2120        day (int): The day of the month.
2121        month (int): The month of the year. Default is 6 (June).
2122        year (int): The year. Default is 2024.
2123
2124        Returns:
2125        int: The timestamp representing the given day, month, and year.
2126
2127        Note:
2128        This method assumes the default month and year if not provided.
2129        """
2130        return ZakatTracker.time(datetime.datetime(year, month, day))
2131
2132    @staticmethod
2133    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2134        """
2135        Generate a random date between two given dates.
2136
2137        Parameters:
2138        start_date (datetime.datetime): The start date from which to generate a random date.
2139        end_date (datetime.datetime): The end date until which to generate a random date.
2140
2141        Returns:
2142        datetime.datetime: A random date between the start_date and end_date.
2143        """
2144        time_between_dates = end_date - start_date
2145        days_between_dates = time_between_dates.days
2146        random_number_of_days = random.randrange(days_between_dates)
2147        return start_date + datetime.timedelta(days=random_number_of_days)
2148
2149    @staticmethod
2150    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
2151                                 debug: bool = False) -> int:
2152        """
2153        Generate a random CSV file with specified parameters.
2154
2155        Parameters:
2156        path (str): The path where the CSV file will be saved. Default is "data.csv".
2157        count (int): The number of rows to generate in the CSV file. Default is 1000.
2158        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
2159        debug (bool): A flag indicating whether to print debug information.
2160
2161        Returns:
2162        None. The function generates a CSV file at the specified path with the given count of rows.
2163        Each row contains a randomly generated account, description, value, and date.
2164        The value is randomly generated between 1000 and 100000,
2165        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2166        If the row number is not divisible by 13, the value is multiplied by -1.
2167        """
2168        if debug:
2169            print('generate_random_csv_file', f'debug={debug}')
2170        i = 0
2171        with open(path, "w", newline="") as csvfile:
2172            writer = csv.writer(csvfile)
2173            for i in range(count):
2174                account = f"acc-{random.randint(1, 1000)}"
2175                desc = f"Some text {random.randint(1, 1000)}"
2176                value = random.randint(1000, 100000)
2177                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2178                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
2179                if not i % 13 == 0:
2180                    value *= -1
2181                row = [account, desc, value, date]
2182                if with_rate:
2183                    rate = random.randint(1, 100) * 0.12
2184                    if debug:
2185                        print('before-append', row)
2186                    row.append(rate)
2187                    if debug:
2188                        print('after-append', row)
2189                writer.writerow(row)
2190                i = i + 1
2191        return i
2192
2193    @staticmethod
2194    def create_random_list(max_sum, min_value=0, max_value=10):
2195        """
2196        Creates a list of random integers whose sum does not exceed the specified maximum.
2197
2198        Args:
2199            max_sum: The maximum allowed sum of the list elements.
2200            min_value: The minimum possible value for an element (inclusive).
2201            max_value: The maximum possible value for an element (inclusive).
2202
2203        Returns:
2204            A list of random integers.
2205        """
2206        result = []
2207        current_sum = 0
2208
2209        while current_sum < max_sum:
2210            # Calculate the remaining space for the next element
2211            remaining_sum = max_sum - current_sum
2212            # Determine the maximum possible value for the next element
2213            next_max_value = min(remaining_sum, max_value)
2214            # Generate a random element within the allowed range
2215            next_element = random.randint(min_value, next_max_value)
2216            result.append(next_element)
2217            current_sum += next_element
2218
2219        return result
2220
2221    def _test_core(self, restore=False, debug=False):
2222
2223        if debug:
2224            random.seed(1234567890)
2225
2226        # sanity check - random forward time
2227
2228        xlist = []
2229        limit = 1000
2230        for _ in range(limit):
2231            y = ZakatTracker.time()
2232            z = '-'
2233            if y not in xlist:
2234                xlist.append(y)
2235            else:
2236                z = 'x'
2237            if debug:
2238                print(z, y)
2239        xx = len(xlist)
2240        if debug:
2241            print('count', xx, ' - unique: ', (xx / limit) * 100, '%')
2242        assert limit == xx
2243
2244        # sanity check - convert date since 1000AD
2245
2246        for year in range(1000, 9000):
2247            ns = ZakatTracker.time(datetime.datetime.strptime(f"{year}-12-30 18:30:45", "%Y-%m-%d %H:%M:%S"))
2248            date = ZakatTracker.time_to_datetime(ns)
2249            if debug:
2250                print(date)
2251            assert date.year == year
2252            assert date.month == 12
2253            assert date.day == 30
2254            assert date.hour == 18
2255            assert date.minute == 30
2256            assert date.second in [44, 45]
2257
2258        # human_readable_size
2259
2260        assert ZakatTracker.human_readable_size(0) == "0.00 B"
2261        assert ZakatTracker.human_readable_size(512) == "512.00 B"
2262        assert ZakatTracker.human_readable_size(1023) == "1023.00 B"
2263
2264        assert ZakatTracker.human_readable_size(1024) == "1.00 KB"
2265        assert ZakatTracker.human_readable_size(2048) == "2.00 KB"
2266        assert ZakatTracker.human_readable_size(5120) == "5.00 KB"
2267
2268        assert ZakatTracker.human_readable_size(1024 ** 2) == "1.00 MB"
2269        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == "2.50 MB"
2270
2271        assert ZakatTracker.human_readable_size(1024 ** 3) == "1.00 GB"
2272        assert ZakatTracker.human_readable_size(1024 ** 4) == "1.00 TB"
2273        assert ZakatTracker.human_readable_size(1024 ** 5) == "1.00 PB"
2274
2275        assert ZakatTracker.human_readable_size(1536, decimal_places=0) == "2 KB"
2276        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == "2.5 MB"
2277        assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == "1.150 GB"
2278
2279        try:
2280            ZakatTracker.human_readable_size("not a number")
2281            assert False, "Expected TypeError for invalid input"
2282        except TypeError:
2283            pass
2284
2285        try:
2286            ZakatTracker.human_readable_size(1024, decimal_places="not an int")
2287            assert False, "Expected TypeError for invalid decimal_places"
2288        except TypeError:
2289            pass
2290
2291        # get_dict_size
2292        assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), "Empty dictionary size mismatch"
2293        assert ZakatTracker.get_dict_size({"a": 1, "b": 2.5, "c": True}) != sys.getsizeof({}), "Not Empty dictionary"
2294
2295        # number scale
2296        error = 0
2297        total = 0
2298        for sign in ['', '-']:
2299            for max_i, max_j, decimal_places in [
2300                (101, 101, 2),  # fiat currency minimum unit took 2 decimal places
2301                (1, 1_000, 8),  # cryptocurrency like Satoshi in Bitcoin took 8 decimal places
2302                (1, 1_000, 18)  # cryptocurrency like Wei in Ethereum took 18 decimal places
2303            ]:
2304                for return_type in (
2305                        float,
2306                        Decimal,
2307                ):
2308                    for i in range(max_i):
2309                        for j in range(max_j):
2310                            total += 1
2311                            num_str = f'{sign}{i}.{j:0{decimal_places}d}'
2312                            num = return_type(num_str)
2313                            scaled = self.scale(num, decimal_places=decimal_places)
2314                            unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places)
2315                            if debug:
2316                                print(
2317                                    f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}')
2318                            if unscaled != num:
2319                                if debug:
2320                                    print('***** SCALE ERROR *****')
2321                                error += 1
2322        if debug:
2323            print(f'total: {total}, error({error}): {100 * error / total}%')
2324        assert error == 0
2325
2326        assert self.nolock()
2327        assert self._history() is True
2328
2329        table = {
2330            1: [
2331                (0, 10, 1000, 1000, 1000, 1, 1),
2332                (0, 20, 3000, 3000, 3000, 2, 2),
2333                (0, 30, 6000, 6000, 6000, 3, 3),
2334                (1, 15, 4500, 4500, 4500, 3, 4),
2335                (1, 50, -500, -500, -500, 4, 5),
2336                (1, 100, -10500, -10500, -10500, 5, 6),
2337            ],
2338            'wallet': [
2339                (1, 90, -9000, -9000, -9000, 1, 1),
2340                (0, 100, 1000, 1000, 1000, 2, 2),
2341                (1, 190, -18000, -18000, -18000, 3, 3),
2342                (0, 1000, 82000, 82000, 82000, 4, 4),
2343            ],
2344        }
2345        for x in table:
2346            for y in table[x]:
2347                self.lock()
2348                if y[0] == 0:
2349                    ref = self.track(
2350                        unscaled_value=y[1],
2351                        desc='test-add',
2352                        account=x,
2353                        logging=True,
2354                        created=ZakatTracker.time(),
2355                        debug=debug,
2356                    )
2357                else:
2358                    (ref, z) = self.sub(
2359                        unscaled_value=y[1],
2360                        desc='test-sub',
2361                        account=x,
2362                        created=ZakatTracker.time(),
2363                    )
2364                    if debug:
2365                        print('_sub', z, ZakatTracker.time())
2366                assert ref != 0
2367                assert len(self._vault['account'][x]['log'][ref]['file']) == 0
2368                for i in range(3):
2369                    file_ref = self.add_file(x, ref, 'file_' + str(i))
2370                    sleep(0.0000001)
2371                    assert file_ref != 0
2372                    if debug:
2373                        print('ref', ref, 'file', file_ref)
2374                    assert len(self._vault['account'][x]['log'][ref]['file']) == i + 1
2375                file_ref = self.add_file(x, ref, 'file_' + str(3))
2376                assert self.remove_file(x, ref, file_ref)
2377                z = self.balance(x)
2378                if debug:
2379                    print("debug-0", z, y)
2380                assert z == y[2]
2381                z = self.balance(x, False)
2382                if debug:
2383                    print("debug-1", z, y[3])
2384                assert z == y[3]
2385                o = self._vault['account'][x]['log']
2386                z = 0
2387                for i in o:
2388                    z += o[i]['value']
2389                if debug:
2390                    print("debug-2", z, type(z))
2391                    print("debug-2", y[4], type(y[4]))
2392                assert z == y[4]
2393                if debug:
2394                    print('debug-2 - PASSED')
2395                assert self.box_size(x) == y[5]
2396                assert self.log_size(x) == y[6]
2397                assert not self.nolock()
2398                self.free(self.lock())
2399                assert self.nolock()
2400            assert self.boxes(x) != {}
2401            assert self.logs(x) != {}
2402
2403            assert not self.hide(x)
2404            assert self.hide(x, False) is False
2405            assert self.hide(x) is False
2406            assert self.hide(x, True)
2407            assert self.hide(x)
2408
2409            assert self.zakatable(x)
2410            assert self.zakatable(x, False) is False
2411            assert self.zakatable(x) is False
2412            assert self.zakatable(x, True)
2413            assert self.zakatable(x)
2414
2415        if restore is True:
2416            count = len(self._vault['history'])
2417            if debug:
2418                print('history-count', count)
2419            assert count == 10
2420            # try mode
2421            for _ in range(count):
2422                assert self.recall(True, debug)
2423            count = len(self._vault['history'])
2424            if debug:
2425                print('history-count', count)
2426            assert count == 10
2427            _accounts = list(table.keys())
2428            accounts_limit = len(_accounts) + 1
2429            for i in range(-1, -accounts_limit, -1):
2430                account = _accounts[i]
2431                if debug:
2432                    print(account, len(table[account]))
2433                transaction_limit = len(table[account]) + 1
2434                for j in range(-1, -transaction_limit, -1):
2435                    row = table[account][j]
2436                    if debug:
2437                        print(row, self.balance(account), self.balance(account, False))
2438                    assert self.balance(account) == self.balance(account, False)
2439                    assert self.balance(account) == row[2]
2440                    assert self.recall(False, debug)
2441            assert self.recall(False, debug) is False
2442            count = len(self._vault['history'])
2443            if debug:
2444                print('history-count', count)
2445            assert count == 0
2446            self.reset()
2447
2448    def test(self, debug: bool = False) -> bool:
2449        if debug:
2450            print('test', f'debug={debug}')
2451        try:
2452
2453            self._test_core(True, debug)
2454            self._test_core(False, debug)
2455
2456            assert self._history()
2457
2458            # Not allowed for duplicate transactions in the same account and time
2459
2460            created = ZakatTracker.time()
2461            self.track(100, 'test-1', 'same', True, created)
2462            failed = False
2463            try:
2464                self.track(50, 'test-1', 'same', True, created)
2465            except:
2466                failed = True
2467            assert failed is True
2468
2469            self.reset()
2470
2471            # Same account transfer
2472            for x in [1, 'a', True, 1.8, None]:
2473                failed = False
2474                try:
2475                    self.transfer(1, x, x, 'same-account', debug=debug)
2476                except:
2477                    failed = True
2478                assert failed is True
2479
2480            # Always preserve box age during transfer
2481
2482            series: list[tuple] = [
2483                (30, 4),
2484                (60, 3),
2485                (90, 2),
2486            ]
2487            case = {
2488                3000: {
2489                    'series': series,
2490                    'rest': 15000,
2491                },
2492                6000: {
2493                    'series': series,
2494                    'rest': 12000,
2495                },
2496                9000: {
2497                    'series': series,
2498                    'rest': 9000,
2499                },
2500                18000: {
2501                    'series': series,
2502                    'rest': 0,
2503                },
2504                27000: {
2505                    'series': series,
2506                    'rest': -9000,
2507                },
2508                36000: {
2509                    'series': series,
2510                    'rest': -18000,
2511                },
2512            }
2513
2514            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2515
2516            for total in case:
2517                if debug:
2518                    print('--------------------------------------------------------')
2519                    print(f'case[{total}]', case[total])
2520                for x in case[total]['series']:
2521                    self.track(
2522                        unscaled_value=x[0],
2523                        desc=f"test-{x} ages",
2524                        account='ages',
2525                        logging=True,
2526                        created=selected_time * x[1],
2527                    )
2528
2529                unscaled_total = self.unscale(total)
2530                if debug:
2531                    print('unscaled_total', unscaled_total)
2532                refs = self.transfer(
2533                    unscaled_amount=unscaled_total,
2534                    from_account='ages',
2535                    to_account='future',
2536                    desc='Zakat Movement',
2537                    debug=debug,
2538                )
2539
2540                if debug:
2541                    print('refs', refs)
2542
2543                ages_cache_balance = self.balance('ages')
2544                ages_fresh_balance = self.balance('ages', False)
2545                rest = case[total]['rest']
2546                if debug:
2547                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2548                assert ages_cache_balance == rest
2549                assert ages_fresh_balance == rest
2550
2551                future_cache_balance = self.balance('future')
2552                future_fresh_balance = self.balance('future', False)
2553                if debug:
2554                    print('target', future_cache_balance, future_fresh_balance, total)
2555                    print('refs', refs)
2556                assert future_cache_balance == total
2557                assert future_fresh_balance == total
2558
2559                # TODO: check boxes times for `ages` should equal box times in `future`
2560                for ref in self._vault['account']['ages']['box']:
2561                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2562                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2563                    future_capital = 0
2564                    if ref in self._vault['account']['future']['box']:
2565                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2566                    future_rest = 0
2567                    if ref in self._vault['account']['future']['box']:
2568                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2569                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2570                        if debug:
2571                            print('================================================================')
2572                            print('ages', ages_capital, ages_rest)
2573                            print('future', future_capital, future_rest)
2574                        if ages_rest == 0:
2575                            assert ages_capital == future_capital
2576                        elif ages_rest < 0:
2577                            assert -ages_capital == future_capital
2578                        elif ages_rest > 0:
2579                            assert ages_capital == ages_rest + future_capital
2580                self.reset()
2581                assert len(self._vault['history']) == 0
2582
2583            assert self._history()
2584            assert self._history(False) is False
2585            assert self._history() is False
2586            assert self._history(True)
2587            assert self._history()
2588            if debug:
2589                print('####################################################################')
2590
2591            transaction = [
2592                (
2593                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
2594                    2000, 2000, 2000, 1, 1,
2595                ),
2596                (
2597                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
2598                    75000, 75000, 75000, 1, 1,
2599                ),
2600                (
2601                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
2602                    60000, 60000, 60000, 1, 1,
2603                ),
2604            ]
2605            for z in transaction:
2606                self.lock()
2607                x = z[1]
2608                y = z[2]
2609                self.transfer(
2610                    unscaled_amount=z[0],
2611                    from_account=x,
2612                    to_account=y,
2613                    desc='test-transfer',
2614                    debug=debug,
2615                )
2616                zz = self.balance(x)
2617                if debug:
2618                    print(zz, z)
2619                assert zz == z[3]
2620                xx = self.accounts()[x]
2621                assert xx == z[3]
2622                assert self.balance(x, False) == z[4]
2623                assert xx == z[4]
2624
2625                s = 0
2626                log = self._vault['account'][x]['log']
2627                for i in log:
2628                    s += log[i]['value']
2629                if debug:
2630                    print('s', s, 'z[5]', z[5])
2631                assert s == z[5]
2632
2633                assert self.box_size(x) == z[6]
2634                assert self.log_size(x) == z[7]
2635
2636                yy = self.accounts()[y]
2637                assert self.balance(y) == z[8]
2638                assert yy == z[8]
2639                assert self.balance(y, False) == z[9]
2640                assert yy == z[9]
2641
2642                s = 0
2643                log = self._vault['account'][y]['log']
2644                for i in log:
2645                    s += log[i]['value']
2646                assert s == z[10]
2647
2648                assert self.box_size(y) == z[11]
2649                assert self.log_size(y) == z[12]
2650                assert self.free(self.lock())
2651
2652            if debug:
2653                pp().pprint(self.check(2.17))
2654
2655            assert not self.nolock()
2656            history_count = len(self._vault['history'])
2657            if debug:
2658                print('history-count', history_count)
2659            assert history_count == 4
2660            assert not self.free(ZakatTracker.time())
2661            assert self.free(self.lock())
2662            assert self.nolock()
2663            assert len(self._vault['history']) == 3
2664
2665            # storage
2666
2667            _path = self.path('test.pickle')
2668            if os.path.exists(_path):
2669                os.remove(_path)
2670            self.save()
2671            assert os.path.getsize(_path) > 0
2672            self.reset()
2673            assert self.recall(False, debug) is False
2674            self.load()
2675            assert self._vault['account'] is not None
2676
2677            # recall
2678
2679            assert self.nolock()
2680            assert len(self._vault['history']) == 3
2681            assert self.recall(False, debug) is True
2682            assert len(self._vault['history']) == 2
2683            assert self.recall(False, debug) is True
2684            assert len(self._vault['history']) == 1
2685            assert self.recall(False, debug) is True
2686            assert len(self._vault['history']) == 0
2687            assert self.recall(False, debug) is False
2688            assert len(self._vault['history']) == 0
2689
2690            # exchange
2691
2692            self.exchange("cash", 25, 3.75, "2024-06-25")
2693            self.exchange("cash", 22, 3.73, "2024-06-22")
2694            self.exchange("cash", 15, 3.69, "2024-06-15")
2695            self.exchange("cash", 10, 3.66)
2696
2697            for i in range(1, 30):
2698                exchange = self.exchange("cash", i)
2699                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2700                if debug:
2701                    print(i, rate, description, created)
2702                assert created
2703                if i < 10:
2704                    assert rate == 1
2705                    assert description is None
2706                elif i == 10:
2707                    assert rate == 3.66
2708                    assert description is None
2709                elif i < 15:
2710                    assert rate == 3.66
2711                    assert description is None
2712                elif i == 15:
2713                    assert rate == 3.69
2714                    assert description is not None
2715                elif i < 22:
2716                    assert rate == 3.69
2717                    assert description is not None
2718                elif i == 22:
2719                    assert rate == 3.73
2720                    assert description is not None
2721                elif i >= 25:
2722                    assert rate == 3.75
2723                    assert description is not None
2724                exchange = self.exchange("bank", i)
2725                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2726                if debug:
2727                    print(i, rate, description, created)
2728                assert created
2729                assert rate == 1
2730                assert description is None
2731
2732            assert len(self._vault['exchange']) > 0
2733            assert len(self.exchanges()) > 0
2734            self._vault['exchange'].clear()
2735            assert len(self._vault['exchange']) == 0
2736            assert len(self.exchanges()) == 0
2737
2738            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2739            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2740            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2741            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2742            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2743
2744            for i in [x * 0.12 for x in range(-15, 21)]:
2745                if i <= 0:
2746                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2747                else:
2748                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2749
2750            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2751            for i in range(1, 31):
2752                timestamp_ns = ZakatTracker.day_to_time(i)
2753                exchange = self.exchange("cash", timestamp_ns)
2754                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2755                if debug:
2756                    print(i, rate, description, created)
2757                assert created
2758                if i < 10:
2759                    assert rate == 1
2760                    assert description is None
2761                elif i == 10:
2762                    assert rate == 3.66
2763                    assert description is None
2764                elif i < 15:
2765                    assert rate == 3.66
2766                    assert description is None
2767                elif i == 15:
2768                    assert rate == 3.69
2769                    assert description is not None
2770                elif i < 22:
2771                    assert rate == 3.69
2772                    assert description is not None
2773                elif i == 22:
2774                    assert rate == 3.73
2775                    assert description is not None
2776                elif i >= 25:
2777                    assert rate == 3.75
2778                    assert description is not None
2779                exchange = self.exchange("bank", i)
2780                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2781                if debug:
2782                    print(i, rate, description, created)
2783                assert created
2784                assert rate == 1
2785                assert description is None
2786
2787            # csv
2788
2789            csv_count = 1000
2790
2791            for with_rate, path in {
2792                False: 'test-import_csv-no-exchange',
2793                True: 'test-import_csv-with-exchange',
2794            }.items():
2795
2796                if debug:
2797                    print('test_import_csv', with_rate, path)
2798
2799                csv_path = path + '.csv'
2800                if os.path.exists(csv_path):
2801                    os.remove(csv_path)
2802                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2803                if debug:
2804                    print('generate_random_csv_file', c)
2805                assert c == csv_count
2806                assert os.path.getsize(csv_path) > 0
2807                cache_path = self.import_csv_cache_path()
2808                if os.path.exists(cache_path):
2809                    os.remove(cache_path)
2810                self.reset()
2811                (created, found, bad) = self.import_csv(csv_path, debug)
2812                bad_count = len(bad)
2813                assert bad_count > 0
2814                if debug:
2815                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2816                    print('bad', bad)
2817                tmp_size = os.path.getsize(cache_path)
2818                assert tmp_size > 0
2819                # TODO: assert created + found + bad_count == csv_count
2820                # TODO: assert created == csv_count
2821                # TODO: assert bad_count == 0
2822                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2823                bad_2_count = len(bad_2)
2824                if debug:
2825                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2826                    print('bad', bad)
2827                assert bad_2_count > 0
2828                # TODO: assert tmp_size == os.path.getsize(cache_path)
2829                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
2830                # TODO: assert created == found_2
2831                # TODO: assert bad_count == bad_2_count
2832                # TODO: assert found_2 == csv_count
2833                # TODO: assert bad_2_count == 0
2834                # TODO: assert created_2 == 0
2835
2836                # payment parts
2837
2838                positive_parts = self.build_payment_parts(100, positive_only=True)
2839                assert self.check_payment_parts(positive_parts) != 0
2840                assert self.check_payment_parts(positive_parts) != 0
2841                all_parts = self.build_payment_parts(300, positive_only=False)
2842                assert self.check_payment_parts(all_parts) != 0
2843                assert self.check_payment_parts(all_parts) != 0
2844                if debug:
2845                    pp().pprint(positive_parts)
2846                    pp().pprint(all_parts)
2847                # dynamic discount
2848                suite = []
2849                count = 3
2850                for exceed in [False, True]:
2851                    case = []
2852                    for parts in [positive_parts, all_parts]:
2853                        part = parts.copy()
2854                        demand = part['demand']
2855                        if debug:
2856                            print(demand, part['total'])
2857                        i = 0
2858                        z = demand / count
2859                        cp = {
2860                            'account': {},
2861                            'demand': demand,
2862                            'exceed': exceed,
2863                            'total': part['total'],
2864                        }
2865                        j = ''
2866                        for x, y in part['account'].items():
2867                            x_exchange = self.exchange(x)
2868                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2869                            if exceed and zz <= demand:
2870                                i += 1
2871                                y['part'] = zz
2872                                if debug:
2873                                    print(exceed, y)
2874                                cp['account'][x] = y
2875                                case.append(y)
2876                            elif not exceed and y['balance'] >= zz:
2877                                i += 1
2878                                y['part'] = zz
2879                                if debug:
2880                                    print(exceed, y)
2881                                cp['account'][x] = y
2882                                case.append(y)
2883                            j = x
2884                            if i >= count:
2885                                break
2886                        if len(cp['account'][j]) > 0:
2887                            suite.append(cp)
2888                if debug:
2889                    print('suite', len(suite))
2890                # vault = self._vault.copy()
2891                for case in suite:
2892                    # self._vault = vault.copy()
2893                    if debug:
2894                        print('case', case)
2895                    result = self.check_payment_parts(case)
2896                    if debug:
2897                        print('check_payment_parts', result, f'exceed: {exceed}')
2898                    assert result == 0
2899
2900                    report = self.check(2.17, None, debug)
2901                    (valid, brief, plan) = report
2902                    if debug:
2903                        print('valid', valid)
2904                    zakat_result = self.zakat(report, parts=case, debug=debug)
2905                    if debug:
2906                        print('zakat-result', zakat_result)
2907                    assert valid == zakat_result
2908
2909            assert self.save(path + '.pickle')
2910            assert self.export_json(path + '.json')
2911
2912            assert self.export_json("1000-transactions-test.json")
2913            assert self.save("1000-transactions-test.pickle")
2914
2915            self.reset()
2916
2917            # test transfer between accounts with different exchange rate
2918
2919            a_SAR = "Bank (SAR)"
2920            b_USD = "Bank (USD)"
2921            c_SAR = "Safe (SAR)"
2922            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2923            for case in [
2924                (0, a_SAR, "SAR Gift", 1000, 100000),
2925                (1, a_SAR, 1),
2926                (0, b_USD, "USD Gift", 500, 50000),
2927                (1, b_USD, 1),
2928                (2, b_USD, 3.75),
2929                (1, b_USD, 3.75),
2930                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 40000, 137500),
2931                (0, c_SAR, "Salary", 750, 75000),
2932                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 37500, 50000),
2933                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 137125, 50100),
2934            ]:
2935                if debug:
2936                    print('case', case)
2937                match (case[0]):
2938                    case 0:  # track
2939                        _, account, desc, x, balance = case
2940                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
2941
2942                        cached_value = self.balance(account, cached=True)
2943                        fresh_value = self.balance(account, cached=False)
2944                        if debug:
2945                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2946                        assert cached_value == balance
2947                        assert fresh_value == balance
2948                    case 1:  # check-exchange
2949                        _, account, expected_rate = case
2950                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2951                        if debug:
2952                            print('t-exchange', t_exchange)
2953                        assert t_exchange['rate'] == expected_rate
2954                    case 2:  # do-exchange
2955                        _, account, rate = case
2956                        self.exchange(account, rate=rate, debug=debug)
2957                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2958                        if debug:
2959                            print('b-exchange', b_exchange)
2960                        assert b_exchange['rate'] == rate
2961                    case 3:  # transfer
2962                        _, x, a, b, desc, a_balance, b_balance = case
2963                        self.transfer(x, a, b, desc, debug=debug)
2964
2965                        cached_value = self.balance(a, cached=True)
2966                        fresh_value = self.balance(a, cached=False)
2967                        if debug:
2968                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value, 'a_balance', a_balance)
2969                        assert cached_value == a_balance
2970                        assert fresh_value == a_balance
2971
2972                        cached_value = self.balance(b, cached=True)
2973                        fresh_value = self.balance(b, cached=False)
2974                        if debug:
2975                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2976                        assert cached_value == b_balance
2977                        assert fresh_value == b_balance
2978
2979            # Transfer all in many chunks randomly from B to A
2980            a_SAR_balance = 137125
2981            b_USD_balance = 50100
2982            b_USD_exchange = self.exchange(b_USD)
2983            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
2984            if debug:
2985                print('amounts', amounts)
2986            i = 0
2987            for x in amounts:
2988                if debug:
2989                    print(f'{i} - transfer-with-exchange({x})')
2990                self.transfer(
2991                    unscaled_amount=self.unscale(x),
2992                    from_account=b_USD,
2993                    to_account=a_SAR,
2994                    desc=f"{x} USD -> SAR",
2995                    debug=debug,
2996                )
2997
2998                b_USD_balance -= x
2999                cached_value = self.balance(b_USD, cached=True)
3000                fresh_value = self.balance(b_USD, cached=False)
3001                if debug:
3002                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3003                          b_USD_balance)
3004                assert cached_value == b_USD_balance
3005                assert fresh_value == b_USD_balance
3006
3007                a_SAR_balance += int(x * b_USD_exchange['rate'])
3008                cached_value = self.balance(a_SAR, cached=True)
3009                fresh_value = self.balance(a_SAR, cached=False)
3010                if debug:
3011                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3012                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
3013                assert cached_value == a_SAR_balance
3014                assert fresh_value == a_SAR_balance
3015                i += 1
3016
3017            # Transfer all in many chunks randomly from C to A
3018            c_SAR_balance = 37500
3019            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
3020            if debug:
3021                print('amounts', amounts)
3022            i = 0
3023            for x in amounts:
3024                if debug:
3025                    print(f'{i} - transfer-with-exchange({x})')
3026                self.transfer(
3027                    unscaled_amount=self.unscale(x),
3028                    from_account=c_SAR,
3029                    to_account=a_SAR,
3030                    desc=f"{x} SAR -> a_SAR",
3031                    debug=debug,
3032                )
3033
3034                c_SAR_balance -= x
3035                cached_value = self.balance(c_SAR, cached=True)
3036                fresh_value = self.balance(c_SAR, cached=False)
3037                if debug:
3038                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3039                          c_SAR_balance)
3040                assert cached_value == c_SAR_balance
3041                assert fresh_value == c_SAR_balance
3042
3043                a_SAR_balance += x
3044                cached_value = self.balance(a_SAR, cached=True)
3045                fresh_value = self.balance(a_SAR, cached=False)
3046                if debug:
3047                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3048                          a_SAR_balance)
3049                assert cached_value == a_SAR_balance
3050                assert fresh_value == a_SAR_balance
3051                i += 1
3052
3053            assert self.export_json("accounts-transfer-with-exchange-rates.json")
3054            assert self.save("accounts-transfer-with-exchange-rates.pickle")
3055
3056            # check & zakat with exchange rates for many cycles
3057
3058            for rate, values in {
3059                1: {
3060                    'in': [1000, 2000, 10000],
3061                    'exchanged': [100000, 200000, 1000000],
3062                    'out': [2500, 5000, 73140],
3063                },
3064                3.75: {
3065                    'in': [200, 1000, 5000],
3066                    'exchanged': [75000, 375000, 1875000],
3067                    'out': [1875, 9375, 137138],
3068                },
3069            }.items():
3070                a, b, c = values['in']
3071                m, n, o = values['exchanged']
3072                x, y, z = values['out']
3073                if debug:
3074                    print('rate', rate, 'values', values)
3075                for case in [
3076                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3077                        {'safe': {0: {'below_nisab': x}}},
3078                    ], False, m),
3079                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3080                        {'safe': {0: {'count': 1, 'total': y}}},
3081                    ], True, n),
3082                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
3083                        {'cave': {0: {'count': 3, 'total': z}}},
3084                    ], True, o),
3085                ]:
3086                    if debug:
3087                        print(f"############# check(rate: {rate}) #############")
3088                        print('case', case)
3089                    self.reset()
3090                    self.exchange(account=case[1], created=case[2], rate=rate)
3091                    self.track(unscaled_value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
3092
3093                    # assert self.nolock()
3094                    # history_size = len(self._vault['history'])
3095                    # print('history_size', history_size)
3096                    # assert history_size == 2
3097                    assert self.lock()
3098                    assert not self.nolock()
3099                    report = self.check(2.17, None, debug)
3100                    (valid, brief, plan) = report
3101                    if debug:
3102                        print('brief', brief)
3103                    assert valid == case[4]
3104                    assert case[5] == brief[0]
3105                    assert case[5] == brief[1]
3106
3107                    if debug:
3108                        pp().pprint(plan)
3109
3110                    for x in plan:
3111                        assert case[1] == x
3112                        if 'total' in case[3][0][x][0].keys():
3113                            assert case[3][0][x][0]['total'] == int(brief[2])
3114                            assert int(plan[x][0]['total']) == case[3][0][x][0]['total']
3115                            assert int(plan[x][0]['count']) == case[3][0][x][0]['count']
3116                        else:
3117                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3118                    if debug:
3119                        pp().pprint(report)
3120                    result = self.zakat(report, debug=debug)
3121                    if debug:
3122                        print('zakat-result', result, case[4])
3123                    assert result == case[4]
3124                    report = self.check(2.17, None, debug)
3125                    (valid, brief, plan) = report
3126                    assert valid is False
3127
3128            history_size = len(self._vault['history'])
3129            if debug:
3130                print('history_size', history_size)
3131            assert history_size == 3
3132            assert not self.nolock()
3133            assert self.recall(False, debug) is False
3134            self.free(self.lock())
3135            assert self.nolock()
3136
3137            for i in range(3, 0, -1):
3138                history_size = len(self._vault['history'])
3139                if debug:
3140                    print('history_size', history_size)
3141                assert history_size == i
3142                assert self.recall(False, debug) is True
3143
3144            assert self.nolock()
3145            assert self.recall(False, debug) is False
3146
3147            history_size = len(self._vault['history'])
3148            if debug:
3149                print('history_size', history_size)
3150            assert history_size == 0
3151
3152            account_size = len(self._vault['account'])
3153            if debug:
3154                print('account_size', account_size)
3155            assert account_size == 0
3156
3157            report_size = len(self._vault['report'])
3158            if debug:
3159                print('report_size', report_size)
3160            assert report_size == 0
3161
3162            assert self.nolock()
3163            return True
3164        except:
3165            # pp().pprint(self._vault)
3166            assert self.export_json("test-snapshot.json")
3167            assert self.save("test-snapshot.pickle")
3168            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)
242    def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True):
243        """
244        Initialize ZakatTracker with database path and history mode.
245
246        Parameters:
247        db_path (str): The path to the database file. Default is "zakat.pickle".
248        history_mode (bool): The mode for tracking history. Default is True.
249
250        Returns:
251        None
252        """
253        self._base_path = None
254        self._vault_path = None
255        self._vault = None
256        self.reset()
257        self._history(history_mode)
258        self.path(db_path)
259        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() -> str:
176    @staticmethod
177    def Version() -> str:
178        """
179        Returns the current version of the software.
180
181        This function returns a string representing the current version of the software,
182        including major, minor, and patch version numbers in the format "X.Y.Z".
183
184        Returns:
185        str: The current version of the software.
186        """
187        return '0.2.81'

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:
189    @staticmethod
190    def ZakatCut(x: float) -> float:
191        """
192        Calculates the Zakat amount due on an asset.
193
194        This function calculates the zakat amount due on a given asset value over one lunar year.
195        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
196        that exceeds a certain threshold (Nisab).
197
198        Parameters:
199        x: The total value of the asset on which Zakat is to be calculated.
200
201        Returns:
202        The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
203        """
204        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:
206    @staticmethod
207    def TimeCycle(days: int = 355) -> int:
208        """
209        Calculates the approximate duration of a lunar year in nanoseconds.
210
211        This function calculates the approximate duration of a lunar year based on the given number of days.
212        It converts the given number of days into nanoseconds for use in high-precision timing applications.
213
214        Parameters:
215        days: The number of days in a lunar year. Defaults to 355,
216              which is an approximation of the average length of a lunar year.
217
218        Returns:
219        The approximate duration of a lunar year in nanoseconds.
220        """
221        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:
223    @staticmethod
224    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
225        """
226        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
227
228        This function calculates the Nisab value, which is the minimum threshold of wealth,
229        that makes an individual liable for paying Zakat.
230        The Nisab value is determined by the equivalent value of a specific amount
231        of gold or silver (currently 595 grams in silver) in the local currency.
232
233        Parameters:
234        - gram_price (float): The price per gram of Nisab.
235        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
236
237        Returns:
238        - float: The total value of Nisab based on the given price per gram.
239        """
240        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:
261    def path(self, path: str = None) -> str:
262        """
263        Set or get the path to the database file.
264
265        If no path is provided, the current path is returned.
266        If a path is provided, it is set as the new path.
267        The function also creates the necessary directories if the provided path is a file.
268
269        Parameters:
270        path (str): The new path to the database file. If not provided, the current path is returned.
271
272        Returns:
273        str: The current or new path to the database file.
274        """
275        if path is None:
276            return self._vault_path
277        self._vault_path = Path(path).resolve()
278        base_path = Path(path).resolve()
279        if base_path.is_file() or base_path.suffix:
280            base_path = base_path.parent
281        base_path.mkdir(parents=True, exist_ok=True)
282        self._base_path = base_path
283        return str(self._vault_path)

Set or get the path to the database file.

If no path is provided, the current path is returned. If a path is provided, it is set as the new path. The function also creates the necessary directories if the provided path is a file.

Parameters: path (str): The new path to the database file. If not provided, the current path is returned.

Returns: str: The current or new path to the database file.

def base_path(self, *args) -> str:
285    def base_path(self, *args) -> str:
286        """
287        Generate a base path by joining the provided arguments with the existing base path.
288
289        Parameters:
290        *args (str): Variable length argument list of strings to be joined with the base path.
291
292        Returns:
293        str: The generated base path. If no arguments are provided, the existing base path is returned.
294        """
295        if not args:
296            return str(self._base_path)
297        filtered_args = []
298        ignored_filename = None
299        for arg in args:
300            if Path(arg).suffix:
301                ignored_filename = arg
302            else:
303                filtered_args.append(arg)
304        base_path = Path(self._base_path)
305        full_path = base_path.joinpath(*filtered_args)
306        full_path.mkdir(parents=True, exist_ok=True)
307        if ignored_filename is not None:
308            return full_path.resolve() / ignored_filename  # Join with the ignored filename
309        return str(full_path.resolve())

Generate a base path by joining the provided arguments with the existing base path.

Parameters: *args (str): Variable length argument list of strings to be joined with the base path.

Returns: str: The generated base path. If no arguments are provided, the existing base path is returned.

@staticmethod
def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
311    @staticmethod
312    def scale(x: float | int | Decimal, decimal_places: int = 2) -> int:
313        """
314        Scales a numerical value by a specified power of 10, returning an integer.
315
316        This function is designed to handle various numeric types (`float`, `int`, or `Decimal`) and
317        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
318
319        Parameters:
320        x: The numeric value to scale. Can be a floating-point number, integer, or decimal.
321        decimal_places: The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
322            by a factor of 100 (e.g., converts 1.23 to 123).
323
324        Returns:
325        The scaled value, rounded to the nearest integer.
326
327        Raises:
328        TypeError: If the input `x` is not a valid numeric type.
329
330        Examples:
331        >>> ZakatTracker.scale(3.14159)
332        314
333        >>> ZakatTracker.scale(1234, decimal_places=3)
334        1234000
335        >>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
336        50
337        """
338        if not isinstance(x, (float, int, Decimal)):
339            raise TypeError("Input 'x' must be a float, int, or Decimal.")
340        return int(Decimal(f"{x:.{decimal_places}f}") * (10 ** decimal_places))

Scales a numerical value by a specified power of 10, returning an integer.

This function is designed to handle various numeric types (float, int, or Decimal) and facilitate precise scaling operations, particularly useful in financial or scientific calculations.

Parameters: x: The numeric value to scale. Can be a floating-point number, integer, or decimal. decimal_places: The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled by a factor of 100 (e.g., converts 1.23 to 123).

Returns: The scaled value, rounded to the nearest integer.

Raises: TypeError: If the input x is not a valid numeric type.

Examples:

>>> ZakatTracker.scale(3.14159)
314
>>> ZakatTracker.scale(1234, decimal_places=3)
1234000
>>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
50
@staticmethod
def unscale( x: int, return_type: type = <class 'float'>, decimal_places: int = 2) -> float | decimal.Decimal:
342    @staticmethod
343    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | Decimal:
344        """
345        Unscales an integer by a power of 10.
346
347        Parameters:
348        x: The integer to unscale.
349        return_type: The desired type for the returned value. Can be float, int, or Decimal. Defaults to float.
350        decimal_places: The power of 10 to use. Defaults to 2.
351
352        Returns:
353        The unscaled number, converted to the specified return_type.
354
355        Raises:
356        TypeError: If the return_type is not float or Decimal.
357        """
358        if return_type not in (float, Decimal):
359            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and Decimal.')
360        return round(return_type(x / (10 ** decimal_places)), decimal_places)

Unscales an integer by a power of 10.

Parameters: x: The integer to unscale. return_type: The desired type for the returned value. Can be float, int, or Decimal. Defaults to float. decimal_places: The power of 10 to use. Defaults to 2.

Returns: The unscaled number, converted to the specified return_type.

Raises: TypeError: If the return_type is not float or Decimal.

def reset(self) -> None:
376    def reset(self) -> None:
377        """
378        Reset the internal data structure to its initial state.
379
380        Parameters:
381        None
382
383        Returns:
384        None
385        """
386        self._vault = {
387            'account': {},
388            'exchange': {},
389            'history': {},
390            'lock': None,
391            'report': {},
392        }

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:
394    @staticmethod
395    def time(now: datetime = None) -> int:
396        """
397        Generates a timestamp based on the provided datetime object or the current datetime.
398
399        Parameters:
400        now (datetime, optional): The datetime object to generate the timestamp from.
401        If not provided, the current datetime is used.
402
403        Returns:
404        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
405            before 1970 will return in negative until 1000AD.
406        """
407        if now is None:
408            now = datetime.datetime.now()
409        ordinal_day = now.toordinal()
410        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
411        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'>:
413    @staticmethod
414    def time_to_datetime(ordinal_ns: int) -> datetime:
415        """
416        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
417
418        Parameters:
419        ordinal_ns (int): The ordinal number of days since 1000-01-01.
420
421        Returns:
422        datetime: The corresponding datetime object.
423        """
424        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
425        ns_in_day = ordinal_ns % 86_400_000_000_000
426        d = datetime.datetime.fromordinal(ordinal_day)
427        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
428        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 clean_history(self, lock: int | None = None) -> int:
430    def clean_history(self, lock: int | None = None) -> int:
431        """
432        Cleans up the history of actions performed on the ZakatTracker instance.
433
434        Parameters:
435        lock (int, optional): The lock ID is used to clean up the empty history.
436            If not provided, it cleans up the empty history records for all locks.
437
438        Returns:
439        int: The number of locks cleaned up.
440        """
441        count = 0
442        if lock in self._vault['history']:
443            if len(self._vault['history'][lock]) <= 0:
444                count += 1
445                del self._vault['history'][lock]
446            return count
447        self.free(self.lock())
448        for lock in self._vault['history']:
449            if len(self._vault['history'][lock]) <= 0:
450                count += 1
451                del self._vault['history'][lock]
452        return count

Cleans up the history of actions performed on the ZakatTracker instance.

Parameters: lock (int, optional): The lock ID is used to clean up the empty history. If not provided, it cleans up the empty history records for all locks.

Returns: int: The number of locks cleaned up.

def nolock(self) -> bool:
490    def nolock(self) -> bool:
491        """
492        Check if the vault lock is currently not set.
493
494        Returns:
495        bool: True if the vault lock is not set, False otherwise.
496        """
497        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:
499    def lock(self) -> int:
500        """
501        Acquires a lock on the ZakatTracker instance.
502
503        Returns:
504        int: The lock ID. This ID can be used to release the lock later.
505        """
506        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 vault(self) -> dict:
508    def vault(self) -> dict:
509        """
510        Returns a copy of the internal vault dictionary.
511
512        This method is used to retrieve the current state of the ZakatTracker object.
513        It provides a snapshot of the internal data structure, allowing for further
514        processing or analysis.
515
516        Returns:
517        dict: A copy of the internal vault dictionary.
518        """
519        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 stats(self) -> dict[str, tuple]:
521    def stats(self) -> dict[str, tuple]:
522        """
523        Calculates and returns statistics about the object's data storage.
524
525        This method determines the size of the database file on disk and the
526        size of the data currently held in RAM (likely within a dictionary).
527        Both sizes are reported in bytes and in a human-readable format
528        (e.g., KB, MB).
529
530        Returns:
531        dict[str, tuple]: A dictionary containing the following statistics:
532
533            * 'database': A tuple with two elements:
534                - The database file size in bytes (int).
535                - The database file size in human-readable format (str).
536            * 'ram': A tuple with two elements:
537                - The RAM usage (dictionary size) in bytes (int).
538                - The RAM usage in human-readable format (str).
539
540        Example:
541        >>> stats = my_object.stats()
542        >>> print(stats['database'])
543        (256000, '250.0 KB')
544        >>> print(stats['ram'])
545        (12345, '12.1 KB')
546        """
547        ram_size = self.get_dict_size(self.vault())
548        file_size = os.path.getsize(self.path())
549        return {
550            'database': (file_size, self.human_readable_size(file_size)),
551            'ram': (ram_size, self.human_readable_size(ram_size)),
552        }

Calculates and returns statistics about the object's data storage.

This method determines the size of the database file on disk and the size of the data currently held in RAM (likely within a dictionary). Both sizes are reported in bytes and in a human-readable format (e.g., KB, MB).

Returns: dict[str, tuple]: A dictionary containing the following statistics:

* 'database': A tuple with two elements:
    - The database file size in bytes (int).
    - The database file size in human-readable format (str).
* 'ram': A tuple with two elements:
    - The RAM usage (dictionary size) in bytes (int).
    - The RAM usage in human-readable format (str).

Example:

>>> stats = my_object.stats()
>>> print(stats['database'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
def files(self) -> list[dict[str, str | int]]:
554    def files(self) -> list[dict[str, str | int]]:
555        """
556        Retrieves information about files associated with this class.
557
558        This class method provides a standardized way to gather details about
559        files used by the class for storage, snapshots, and CSV imports.
560
561        Returns:
562        list[dict[str, str | int]]: A list of dictionaries, each containing information
563            about a specific file:
564
565            * type (str): The type of file ('database', 'snapshot', 'import_csv').
566            * path (str): The full file path.
567            * exists (bool): Whether the file exists on the filesystem.
568            * size (int): The file size in bytes (0 if the file doesn't exist).
569            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
570
571        Example:
572        ```
573        file_info = MyClass.files()
574        for info in file_info:
575            print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
576        ```
577        """
578        result = []
579        for file_type, path in {
580            'database': self.path(),
581            'snapshot': self.snapshot_cache_path(),
582            'import_csv': self.import_csv_cache_path(),
583        }.items():
584            exists = os.path.exists(path)
585            size = os.path.getsize(path) if exists else 0
586            human_readable_size = self.human_readable_size(size) if exists else 0
587            result.append({
588                'type': file_type,
589                'path': path,
590                'exists': exists,
591                'size': size,
592                'human_readable_size': human_readable_size,
593            })
594        return result

Retrieves information about files associated with this class.

This class method provides a standardized way to gather details about files used by the class for storage, snapshots, and CSV imports.

Returns: list[dict[str, str | int]]: A list of dictionaries, each containing information about a specific file:

* type (str): The type of file ('database', 'snapshot', 'import_csv').
* path (str): The full file path.
* exists (bool): Whether the file exists on the filesystem.
* size (int): The file size in bytes (0 if the file doesn't exist).
* human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').

Example:

file_info = MyClass.files()
for info in file_info:
    print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
def steps(self) -> dict:
596    def steps(self) -> dict:
597        """
598        Returns a copy of the history of steps taken in the ZakatTracker.
599
600        The history is a dictionary where each key is a unique identifier for a step,
601        and the corresponding value is a dictionary containing information about the step.
602
603        Returns:
604        dict: A copy of the history of steps taken in the ZakatTracker.
605        """
606        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:
608    def free(self, lock: int, auto_save: bool = True) -> bool:
609        """
610        Releases the lock on the database.
611
612        Parameters:
613        lock (int): The lock ID to be released.
614        auto_save (bool): Whether to automatically save the database after releasing the lock.
615
616        Returns:
617        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
618        """
619        if lock == self._vault['lock']:
620            self._vault['lock'] = None
621            self.clean_history(lock)
622            if auto_save:
623                return self.save(self.path())
624            return True
625        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:
627    def account_exists(self, account) -> bool:
628        """
629        Check if the given account exists in the vault.
630
631        Parameters:
632        account (str): The account number to check.
633
634        Returns:
635        bool: True if the account exists, False otherwise.
636        """
637        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:
639    def box_size(self, account) -> int:
640        """
641        Calculate the size of the box for a specific account.
642
643        Parameters:
644        account (str): The account number for which the box size needs to be calculated.
645
646        Returns:
647        int: The size of the box for the given account. If the account does not exist, -1 is returned.
648        """
649        if self.account_exists(account):
650            return len(self._vault['account'][account]['box'])
651        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:
653    def log_size(self, account) -> int:
654        """
655        Get the size of the log for a specific account.
656
657        Parameters:
658        account (str): The account number for which the log size needs to be calculated.
659
660        Returns:
661        int: The size of the log for the given account. If the account does not exist, -1 is returned.
662        """
663        if self.account_exists(account):
664            return len(self._vault['account'][account]['log'])
665        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.

@staticmethod
def file_hash(file_path: str, algorithm: str = 'blake2b') -> str:
667    @staticmethod
668    def file_hash(file_path: str, algorithm: str = "blake2b") -> str:
669        """
670        Calculates the hash of a file using the specified algorithm.
671
672        Parameters:
673        file_path (str): The path to the file.
674        algorithm (str, optional): The hashing algorithm to use. Defaults to "blake2b".
675
676        Returns:
677        str: The hexadecimal representation of the file's hash.
678        """
679        hash_obj = hashlib.new(algorithm)  # Create the hash object
680        with open(file_path, "rb") as f:  # Open file in binary mode for reading
681            for chunk in iter(lambda: f.read(4096), b""):  # Read file in chunks
682                hash_obj.update(chunk)
683        return hash_obj.hexdigest()  # Return the hash as a hexadecimal string

Calculates the hash of a file using the specified algorithm.

Parameters: file_path (str): The path to the file. algorithm (str, optional): The hashing algorithm to use. Defaults to "blake2b".

Returns: str: The hexadecimal representation of the file's hash.

def snapshot_cache_path(self):
685    def snapshot_cache_path(self):
686        """
687        Generate the path for the cache file used to store snapshots.
688
689        The cache file is a pickle file that stores the timestamps of the snapshots.
690        The file name is derived from the main database file name by replacing the ".pickle" extension with ".snapshots.pickle".
691
692        Returns:
693        str: The path to the cache file.
694        """
695        path = str(self.path())
696        if path.endswith(".pickle"):
697            path = path[:-7]
698        return path + '.snapshots.pickle'

Generate the path for the cache file used to store snapshots.

The cache file is a pickle file that stores the timestamps of the snapshots. The file name is derived from the main database file name by replacing the ".pickle" extension with ".snapshots.pickle".

Returns: str: The path to the cache file.

def snapshot(self) -> bool:
700    def snapshot(self) -> bool:
701        """
702        This function creates a snapshot of the current database state.
703
704        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
705        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
706        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
707        in a new pickle file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp.
708
709        Parameters:
710        None
711
712        Returns:
713        bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
714        """
715        current_hash = self.file_hash(self.path())
716        cache: dict[str, int] = {}  # hash: time_ns
717        try:
718            with open(self.snapshot_cache_path(), "rb") as f:
719                cache = pickle.load(f)
720        except:
721            pass
722        if current_hash in cache:
723            return True
724        time = time_ns()
725        cache[current_hash] = time
726        if not self.save(self.base_path('snapshots', f'{time}.pickle')):
727            return False
728        with open(self.snapshot_cache_path(), "wb") as f:
729            pickle.dump(cache, f)
730        return True

This function creates a snapshot of the current database state.

The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. If a snapshot with the same hash exists, the function returns True without creating a new snapshot. If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state in a new pickle file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp.

Parameters: None

Returns: bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.

def snapshots( self, hide_missing: bool = True, verified_hash_only: bool = False) -> dict[int, tuple[str, str, bool]]:
732    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
733            -> dict[int, tuple[str, str, bool]]:
734        """
735        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
736
737        Parameters:
738        - hide_missing (bool): If True, only include snapshots that exist in the dictionary. Default is True.
739        - verified_hash_only (bool): If True, only include snapshots with a valid hash. Default is False.
740
741        Returns:
742        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
743        and the values are tuples containing the snapshot's hash, path, and existence status.
744        """
745        cache: dict[str, int] = {}  # hash: time_ns
746        try:
747            with open(self.snapshot_cache_path(), "rb") as f:
748                cache = pickle.load(f)
749        except:
750            pass
751        if not cache:
752            return {}
753        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
754        for file_hash, ref in cache.items():
755            path = self.base_path('snapshots', f'{ref}.pickle')
756            exists = os.path.exists(path)
757            valid_hash = self.file_hash(path) == file_hash if verified_hash_only else True
758            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
759                continue
760            if exists or not hide_missing:
761                result[ref] = (file_hash, path, exists)
762        return result

Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.

Parameters:

  • hide_missing (bool): If True, only include snapshots that exist in the dictionary. Default is True.
  • verified_hash_only (bool): If True, only include snapshots with a valid hash. Default is False.

Returns:

  • dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, and the values are tuples containing the snapshot's hash, path, and existence status.
def recall(self, dry=True, debug=False) -> bool:
764    def recall(self, dry=True, debug=False) -> bool:
765        """
766        Revert the last operation.
767
768        Parameters:
769        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
770        debug (bool): If True, the function will print debug information. Default is False.
771
772        Returns:
773        bool: True if the operation was successful, False otherwise.
774        """
775        if not self.nolock() or len(self._vault['history']) == 0:
776            return False
777        if len(self._vault['history']) <= 0:
778            return False
779        ref = sorted(self._vault['history'].keys())[-1]
780        if debug:
781            print('recall', ref)
782        memory = self._vault['history'][ref]
783        if debug:
784            print(type(memory), 'memory', memory)
785        limit = len(memory) + 1
786        sub_positive_log_negative = 0
787        for i in range(-1, -limit, -1):
788            x = memory[i]
789            if debug:
790                print(type(x), x)
791            match x['action']:
792                case Action.CREATE:
793                    if x['account'] is not None:
794                        if self.account_exists(x['account']):
795                            if debug:
796                                print('account', self._vault['account'][x['account']])
797                            assert len(self._vault['account'][x['account']]['box']) == 0
798                            assert self._vault['account'][x['account']]['balance'] == 0
799                            assert self._vault['account'][x['account']]['count'] == 0
800                            if dry:
801                                continue
802                            del self._vault['account'][x['account']]
803
804                case Action.TRACK:
805                    if x['account'] is not None:
806                        if self.account_exists(x['account']):
807                            if dry:
808                                continue
809                            self._vault['account'][x['account']]['balance'] -= x['value']
810                            self._vault['account'][x['account']]['count'] -= 1
811                            del self._vault['account'][x['account']]['box'][x['ref']]
812
813                case Action.LOG:
814                    if x['account'] is not None:
815                        if self.account_exists(x['account']):
816                            if x['ref'] in self._vault['account'][x['account']]['log']:
817                                if dry:
818                                    continue
819                                if sub_positive_log_negative == -x['value']:
820                                    self._vault['account'][x['account']]['count'] -= 1
821                                    sub_positive_log_negative = 0
822                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
823                                if not box_ref is None:
824                                    assert self.box_exists(x['account'], box_ref)
825                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
826                                    assert box_value < 0
827
828                                    try:
829                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
830                                    except TypeError:
831                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += Decimal(
832                                            -box_value)
833
834                                    try:
835                                        self._vault['account'][x['account']]['balance'] += -box_value
836                                    except TypeError:
837                                        self._vault['account'][x['account']]['balance'] += Decimal(-box_value)
838
839                                    self._vault['account'][x['account']]['count'] -= 1
840                                del self._vault['account'][x['account']]['log'][x['ref']]
841
842                case Action.SUB:
843                    if x['account'] is not None:
844                        if self.account_exists(x['account']):
845                            if x['ref'] in self._vault['account'][x['account']]['box']:
846                                if dry:
847                                    continue
848                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
849                                self._vault['account'][x['account']]['balance'] += x['value']
850                                sub_positive_log_negative = x['value']
851
852                case Action.ADD_FILE:
853                    if x['account'] is not None:
854                        if self.account_exists(x['account']):
855                            if x['ref'] in self._vault['account'][x['account']]['log']:
856                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
857                                    if dry:
858                                        continue
859                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
860
861                case Action.REMOVE_FILE:
862                    if x['account'] is not None:
863                        if self.account_exists(x['account']):
864                            if x['ref'] in self._vault['account'][x['account']]['log']:
865                                if dry:
866                                    continue
867                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
868
869                case Action.BOX_TRANSFER:
870                    if x['account'] is not None:
871                        if self.account_exists(x['account']):
872                            if x['ref'] in self._vault['account'][x['account']]['box']:
873                                if dry:
874                                    continue
875                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
876
877                case Action.EXCHANGE:
878                    if x['account'] is not None:
879                        if x['account'] in self._vault['exchange']:
880                            if x['ref'] in self._vault['exchange'][x['account']]:
881                                if dry:
882                                    continue
883                                del self._vault['exchange'][x['account']][x['ref']]
884
885                case Action.REPORT:
886                    if x['ref'] in self._vault['report']:
887                        if dry:
888                            continue
889                        del self._vault['report'][x['ref']]
890
891                case Action.ZAKAT:
892                    if x['account'] is not None:
893                        if self.account_exists(x['account']):
894                            if x['ref'] in self._vault['account'][x['account']]['box']:
895                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
896                                    if dry:
897                                        continue
898                                    match x['math']:
899                                        case MathOperation.ADDITION:
900                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
901                                                'value']
902                                        case MathOperation.EQUAL:
903                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
904                                        case MathOperation.SUBTRACTION:
905                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
906                                                'value']
907
908        if not dry:
909            del self._vault['history'][ref]
910        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:
912    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
913        """
914        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
915
916        Parameters:
917        account (str): The account number for which to check the existence of the reference.
918        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
919        ref (int): The reference (transaction) number to check for existence.
920
921        Returns:
922        bool: True if the reference exists for the given account and reference type, False otherwise.
923        """
924        if account in self._vault['account']:
925            return ref in self._vault['account'][account][ref_type]
926        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:
928    def box_exists(self, account: str, ref: int) -> bool:
929        """
930        Check if a specific box (transaction) exists in the vault for a given account and reference.
931
932        Parameters:
933        - account (str): The account number for which to check the existence of the box.
934        - ref (int): The reference (transaction) number to check for existence.
935
936        Returns:
937        - bool: True if the box exists for the given account and reference, False otherwise.
938        """
939        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, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
 941    def track(self, unscaled_value: float | int | Decimal = 0, desc: str = '', account: str = 1, logging: bool = True,
 942              created: int = None,
 943              debug: bool = False) -> int:
 944        """
 945        This function tracks a transaction for a specific account.
 946
 947        Parameters:
 948        unscaled_value (float | int | Decimal): The value of the transaction. Default is 0.
 949        desc (str): The description of the transaction. Default is an empty string.
 950        account (str): The account for which the transaction is being tracked. Default is '1'.
 951        logging (bool): Whether to log the transaction. Default is True.
 952        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
 953        debug (bool): Whether to print debug information. Default is False.
 954
 955        Returns:
 956        int: The timestamp of the transaction.
 957
 958        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.
 959
 960        Raises:
 961        ValueError: The log transaction happened again in the same nanosecond time.
 962        ValueError: The box transaction happened again in the same nanosecond time.
 963        """
 964        if debug:
 965            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
 966        if created is None:
 967            created = self.time()
 968        no_lock = self.nolock()
 969        self.lock()
 970        if not self.account_exists(account):
 971            if debug:
 972                print(f"account {account} created")
 973            self._vault['account'][account] = {
 974                'balance': 0,
 975                'box': {},
 976                'count': 0,
 977                'log': {},
 978                'hide': False,
 979                'zakatable': True,
 980            }
 981            self._step(Action.CREATE, account)
 982        if unscaled_value == 0:
 983            if no_lock:
 984                self.free(self.lock())
 985            return 0
 986        value = self.scale(unscaled_value)
 987        if logging:
 988            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
 989        if debug:
 990            print('create-box', created)
 991        if self.box_exists(account, created):
 992            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
 993        if debug:
 994            print('created-box', created)
 995        self._vault['account'][account]['box'][created] = {
 996            'capital': value,
 997            'count': 0,
 998            'last': 0,
 999            'rest': value,
1000            'total': 0,
1001        }
1002        self._step(Action.TRACK, account, ref=created, value=value)
1003        if no_lock:
1004            self.free(self.lock())
1005        return created

This function tracks a transaction for a specific account.

Parameters: unscaled_value (float | int | Decimal): 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:
1007    def log_exists(self, account: str, ref: int) -> bool:
1008        """
1009        Checks if a specific transaction log entry exists for a given account.
1010
1011        Parameters:
1012        account (str): The account number associated with the transaction log.
1013        ref (int): The reference to the transaction log entry.
1014
1015        Returns:
1016        bool: True if the transaction log entry exists, False otherwise.
1017        """
1018        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:
1064    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
1065                 debug: bool = False) -> dict:
1066        """
1067        This method is used to record or retrieve exchange rates for a specific account.
1068
1069        Parameters:
1070        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1071        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1072        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1073        - description (str): A description of the exchange rate.
1074
1075        Returns:
1076        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1077        it returns a dictionary with default values for the rate and description.
1078        """
1079        if debug:
1080            print('exchange', f'debug={debug}')
1081        if created is None:
1082            created = self.time()
1083        no_lock = self.nolock()
1084        self.lock()
1085        if rate is not None:
1086            if rate <= 0:
1087                return dict()
1088            if account not in self._vault['exchange']:
1089                self._vault['exchange'][account] = {}
1090            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1091                return {"time": created, "rate": 1, "description": None}
1092            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
1093            self._step(Action.EXCHANGE, account, ref=created, value=rate)
1094            if no_lock:
1095                self.free(self.lock())
1096            if debug:
1097                print("exchange-created-1",
1098                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1099
1100        if account in self._vault['exchange']:
1101            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
1102            if valid_rates:
1103                latest_rate = max(valid_rates, key=lambda x: x[0])
1104                if debug:
1105                    print("exchange-read-1",
1106                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
1107                          'latest_rate', latest_rate)
1108                result = latest_rate[1]
1109                result['time'] = latest_rate[0]
1110                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1111        if debug:
1112            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1113        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:
1115    @staticmethod
1116    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1117        """
1118        This function calculates the exchanged amount of a currency.
1119
1120        Args:
1121            x (float): The original amount of the currency.
1122            x_rate (float): The exchange rate of the original currency.
1123            y_rate (float): The exchange rate of the target currency.
1124
1125        Returns:
1126            float: The exchanged amount of the target currency.
1127        """
1128        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:
1130    def exchanges(self) -> dict:
1131        """
1132        Retrieve the recorded exchange rates for all accounts.
1133
1134        Parameters:
1135        None
1136
1137        Returns:
1138        dict: A dictionary containing all recorded exchange rates.
1139        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1140        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1141        """
1142        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:
1144    def accounts(self) -> dict:
1145        """
1146        Returns a dictionary containing account numbers as keys and their respective balances as values.
1147
1148        Parameters:
1149        None
1150
1151        Returns:
1152        dict: A dictionary where keys are account numbers and values are their respective balances.
1153        """
1154        result = {}
1155        for i in self._vault['account']:
1156            result[i] = self._vault['account'][i]['balance']
1157        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:
1159    def boxes(self, account) -> dict:
1160        """
1161        Retrieve the boxes (transactions) associated with a specific account.
1162
1163        Parameters:
1164        account (str): The account number for which to retrieve the boxes.
1165
1166        Returns:
1167        dict: A dictionary containing the boxes associated with the given account.
1168        If the account does not exist, an empty dictionary is returned.
1169        """
1170        if self.account_exists(account):
1171            return self._vault['account'][account]['box']
1172        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:
1174    def logs(self, account) -> dict:
1175        """
1176        Retrieve the logs (transactions) associated with a specific account.
1177
1178        Parameters:
1179        account (str): The account number for which to retrieve the logs.
1180
1181        Returns:
1182        dict: A dictionary containing the logs associated with the given account.
1183        If the account does not exist, an empty dictionary is returned.
1184        """
1185        if self.account_exists(account):
1186            return self._vault['account'][account]['log']
1187        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:
1189    def add_file(self, account: str, ref: int, path: str) -> int:
1190        """
1191        Adds a file reference to a specific transaction log entry in the vault.
1192
1193        Parameters:
1194        account (str): The account number associated with the transaction log.
1195        ref (int): The reference to the transaction log entry.
1196        path (str): The path of the file to be added.
1197
1198        Returns:
1199        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1200        """
1201        if self.account_exists(account):
1202            if ref in self._vault['account'][account]['log']:
1203                file_ref = self.time()
1204                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1205                no_lock = self.nolock()
1206                self.lock()
1207                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1208                if no_lock:
1209                    self.free(self.lock())
1210                return file_ref
1211        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:
1213    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1214        """
1215        Removes a file reference from a specific transaction log entry in the vault.
1216
1217        Parameters:
1218        account (str): The account number associated with the transaction log.
1219        ref (int): The reference to the transaction log entry.
1220        file_ref (int): The reference of the file to be removed.
1221
1222        Returns:
1223        bool: True if the file reference is successfully removed, False otherwise.
1224        """
1225        if self.account_exists(account):
1226            if ref in self._vault['account'][account]['log']:
1227                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1228                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1229                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1230                    no_lock = self.nolock()
1231                    self.lock()
1232                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1233                    if no_lock:
1234                        self.free(self.lock())
1235                    return True
1236        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:
1238    def balance(self, account: str = 1, cached: bool = True) -> int:
1239        """
1240        Calculate and return the balance of a specific account.
1241
1242        Parameters:
1243        account (str): The account number. Default is '1'.
1244        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1245
1246        Returns:
1247        int: The balance of the account.
1248
1249        Note:
1250        If cached is True, the function returns the cached balance.
1251        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1252        """
1253        if cached:
1254            return self._vault['account'][account]['balance']
1255        x = 0
1256        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:
1258    def hide(self, account, status: bool = None) -> bool:
1259        """
1260        Check or set the hide status of a specific account.
1261
1262        Parameters:
1263        account (str): The account number.
1264        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1265
1266        Returns:
1267        bool: The current or updated hide status of the account.
1268
1269        Raises:
1270        None
1271
1272        Example:
1273        >>> tracker = ZakatTracker()
1274        >>> ref = tracker.track(51, 'desc', 'account1')
1275        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1276        False
1277        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1278        True
1279        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1280        True
1281        >>> tracker.hide('account1', False)
1282        False
1283        """
1284        if self.account_exists(account):
1285            if status is None:
1286                return self._vault['account'][account]['hide']
1287            self._vault['account'][account]['hide'] = status
1288            return status
1289        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:
1291    def zakatable(self, account, status: bool = None) -> bool:
1292        """
1293        Check or set the zakatable status of a specific account.
1294
1295        Parameters:
1296        account (str): The account number.
1297        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1298
1299        Returns:
1300        bool: The current or updated zakatable status of the account.
1301
1302        Raises:
1303        None
1304
1305        Example:
1306        >>> tracker = ZakatTracker()
1307        >>> ref = tracker.track(51, 'desc', 'account1')
1308        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1309        True
1310        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1311        True
1312        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1313        True
1314        >>> tracker.zakatable('account1', False)
1315        False
1316        """
1317        if self.account_exists(account):
1318            if status is None:
1319                return self._vault['account'][account]['zakatable']
1320            self._vault['account'][account]['zakatable'] = status
1321            return status
1322        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, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple[int, list[tuple[int, int]]] | tuple:
1324    def sub(self, unscaled_value: float | int | Decimal, desc: str = '', account: str = 1, created: int = None,
1325            debug: bool = False) \
1326            -> tuple[
1327                int,
1328                list[
1329                    tuple[int, int],
1330                ],
1331            ] | tuple:
1332        """
1333        Subtracts a specified value from an account's balance.
1334
1335        Parameters:
1336        unscaled_value (float | int | Decimal): The amount to be subtracted.
1337        desc (str): A description for the transaction. Defaults to an empty string.
1338        account (str): The account from which the value will be subtracted. Defaults to '1'.
1339        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1340        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1341
1342        Returns:
1343        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1344
1345        If the amount to subtract is greater than the account's balance,
1346        the remaining amount will be transferred to a new transaction with a negative value.
1347
1348        Raises:
1349        ValueError: The box transaction happened again in the same nanosecond time.
1350        ValueError: The log transaction happened again in the same nanosecond time.
1351        """
1352        if debug:
1353            print('sub', f'debug={debug}')
1354        if unscaled_value < 0:
1355            return tuple()
1356        if unscaled_value == 0:
1357            ref = self.track(unscaled_value, '', account)
1358            return ref, ref
1359        if created is None:
1360            created = self.time()
1361        no_lock = self.nolock()
1362        self.lock()
1363        self.track(0, '', account)
1364        value = self.scale(unscaled_value)
1365        self._log(value=-value, desc=desc, account=account, created=created, ref=None, debug=debug)
1366        ids = sorted(self._vault['account'][account]['box'].keys())
1367        limit = len(ids) + 1
1368        target = value
1369        if debug:
1370            print('ids', ids)
1371        ages = []
1372        for i in range(-1, -limit, -1):
1373            if target == 0:
1374                break
1375            j = ids[i]
1376            if debug:
1377                print('i', i, 'j', j)
1378            rest = self._vault['account'][account]['box'][j]['rest']
1379            if rest >= target:
1380                self._vault['account'][account]['box'][j]['rest'] -= target
1381                self._step(Action.SUB, account, ref=j, value=target)
1382                ages.append((j, target))
1383                target = 0
1384                break
1385            elif target > rest > 0:
1386                chunk = rest
1387                target -= chunk
1388                self._step(Action.SUB, account, ref=j, value=chunk)
1389                ages.append((j, chunk))
1390                self._vault['account'][account]['box'][j]['rest'] = 0
1391        if target > 0:
1392            self.track(
1393                unscaled_value=self.unscale(-target),
1394                desc=desc,
1395                account=account,
1396                logging=False,
1397                created=created,
1398            )
1399            ages.append((created, target))
1400        if no_lock:
1401            self.free(self.lock())
1402        return created, ages

Subtracts a specified value from an account's balance.

Parameters: unscaled_value (float | int | Decimal): 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, unscaled_amount: float | int | decimal.Decimal, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
1404    def transfer(self, unscaled_amount: float | int | Decimal, from_account: str, to_account: str, desc: str = '',
1405                 created: int = None,
1406                 debug: bool = False) -> list[int]:
1407        """
1408        Transfers a specified value from one account to another.
1409
1410        Parameters:
1411        unscaled_amount (float | int | Decimal): The amount to be transferred.
1412        from_account (str): The account from which the value will be transferred.
1413        to_account (str): The account to which the value will be transferred.
1414        desc (str, optional): A description for the transaction. Defaults to an empty string.
1415        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1416        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1417
1418        Returns:
1419        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1420
1421        Raises:
1422        ValueError: Transfer to the same account is forbidden.
1423        ValueError: The box transaction happened again in the same nanosecond time.
1424        ValueError: The log transaction happened again in the same nanosecond time.
1425        """
1426        if debug:
1427            print('transfer', f'debug={debug}')
1428        if from_account == to_account:
1429            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1430        if unscaled_amount <= 0:
1431            return []
1432        if created is None:
1433            created = self.time()
1434        (_, ages) = self.sub(unscaled_amount, desc, from_account, created, debug=debug)
1435        times = []
1436        source_exchange = self.exchange(from_account, created)
1437        target_exchange = self.exchange(to_account, created)
1438
1439        if debug:
1440            print('ages', ages)
1441
1442        for age, value in ages:
1443            target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']))
1444            if debug:
1445                print('target_amount', target_amount)
1446            # Perform the transfer
1447            if self.box_exists(to_account, age):
1448                if debug:
1449                    print('box_exists', age)
1450                capital = self._vault['account'][to_account]['box'][age]['capital']
1451                rest = self._vault['account'][to_account]['box'][age]['rest']
1452                if debug:
1453                    print(
1454                        f"Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1455                selected_age = age
1456                if rest + target_amount > capital:
1457                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1458                    selected_age = ZakatTracker.time()
1459                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1460                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1461                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1462                              created=None, ref=None, debug=debug)
1463                times.append((age, y))
1464                continue
1465            if debug:
1466                print(
1467                    f"Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1468            y = self.track(
1469                unscaled_value=self.unscale(int(target_amount)),
1470                desc=desc,
1471                account=to_account,
1472                logging=True,
1473                created=age,
1474                debug=debug,
1475            )
1476            times.append(y)
1477        return times

Transfers a specified value from one account to another.

Parameters: unscaled_amount (float | int | Decimal): 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, unscaled_nisab: float | int | decimal.Decimal = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
1479    def check(self, silver_gram_price: float, unscaled_nisab: float | int | Decimal = None, debug: bool = False, now: int = None,
1480              cycle: float = None) -> tuple:
1481        """
1482        Check the eligibility for Zakat based on the given parameters.
1483
1484        Parameters:
1485        silver_gram_price (float): The price of a gram of silver.
1486        unscaled_nisab (float | int | Decimal): The minimum amount of wealth required for Zakat. If not provided,
1487                        it will be calculated based on the silver_gram_price.
1488        debug (bool): Flag to enable debug mode.
1489        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1490        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1491
1492        Returns:
1493        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1494        and a dictionary containing the Zakat plan.
1495        """
1496        if debug:
1497            print('check', f'debug={debug}')
1498        if now is None:
1499            now = self.time()
1500        if cycle is None:
1501            cycle = ZakatTracker.TimeCycle()
1502        if unscaled_nisab is None:
1503            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
1504        nisab = self.scale(unscaled_nisab)
1505        plan = {}
1506        below_nisab = 0
1507        brief = [0, 0, 0]
1508        valid = False
1509        if debug:
1510            print('exchanges', self.exchanges())
1511        for x in self._vault['account']:
1512            if not self.zakatable(x):
1513                continue
1514            _box = self._vault['account'][x]['box']
1515            _log = self._vault['account'][x]['log']
1516            limit = len(_box) + 1
1517            ids = sorted(self._vault['account'][x]['box'].keys())
1518            for i in range(-1, -limit, -1):
1519                j = ids[i]
1520                rest = float(_box[j]['rest'])
1521                if rest <= 0:
1522                    continue
1523                exchange = self.exchange(x, created=self.time())
1524                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1525                brief[0] += rest
1526                index = limit + i - 1
1527                epoch = (now - j) / cycle
1528                if debug:
1529                    print(f"Epoch: {epoch}", _box[j])
1530                if _box[j]['last'] > 0:
1531                    epoch = (now - _box[j]['last']) / cycle
1532                if debug:
1533                    print(f"Epoch: {epoch}")
1534                epoch = floor(epoch)
1535                if debug:
1536                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1537                if epoch == 0:
1538                    continue
1539                if debug:
1540                    print("Epoch - PASSED")
1541                brief[1] += rest
1542                if rest >= nisab:
1543                    total = 0
1544                    for _ in range(epoch):
1545                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1546                    if total > 0:
1547                        if x not in plan:
1548                            plan[x] = {}
1549                        valid = True
1550                        brief[2] += total
1551                        plan[x][index] = {
1552                            'total': total,
1553                            'count': epoch,
1554                            'box_time': j,
1555                            'box_capital': _box[j]['capital'],
1556                            'box_rest': _box[j]['rest'],
1557                            'box_last': _box[j]['last'],
1558                            'box_total': _box[j]['total'],
1559                            'box_count': _box[j]['count'],
1560                            'box_log': _log[j]['desc'],
1561                            'exchange_rate': exchange['rate'],
1562                            'exchange_time': exchange['time'],
1563                            'exchange_desc': exchange['description'],
1564                        }
1565                else:
1566                    chunk = ZakatTracker.ZakatCut(float(rest))
1567                    if chunk > 0:
1568                        if x not in plan:
1569                            plan[x] = {}
1570                        if j not in plan[x].keys():
1571                            plan[x][index] = {}
1572                        below_nisab += rest
1573                        brief[2] += chunk
1574                        plan[x][index]['below_nisab'] = chunk
1575                        plan[x][index]['total'] = chunk
1576                        plan[x][index]['count'] = epoch
1577                        plan[x][index]['box_time'] = j
1578                        plan[x][index]['box_capital'] = _box[j]['capital']
1579                        plan[x][index]['box_rest'] = _box[j]['rest']
1580                        plan[x][index]['box_last'] = _box[j]['last']
1581                        plan[x][index]['box_total'] = _box[j]['total']
1582                        plan[x][index]['box_count'] = _box[j]['count']
1583                        plan[x][index]['box_log'] = _log[j]['desc']
1584                        plan[x][index]['exchange_rate'] = exchange['rate']
1585                        plan[x][index]['exchange_time'] = exchange['time']
1586                        plan[x][index]['exchange_desc'] = exchange['description']
1587        valid = valid or below_nisab >= nisab
1588        if debug:
1589            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1590        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. unscaled_nisab (float | int | Decimal): 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:
1592    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1593        """
1594        Build payment parts for the Zakat distribution.
1595
1596        Parameters:
1597        demand (float): The total demand for payment in local currency.
1598        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1599
1600        Returns:
1601        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1602        {
1603            'account': {
1604                'account_id': {'balance': float, 'rate': float, 'part': float},
1605                ...
1606            },
1607            'exceed': bool,
1608            'demand': float,
1609            'total': float,
1610        }
1611        """
1612        total = 0
1613        parts = {
1614            'account': {},
1615            'exceed': False,
1616            'demand': demand,
1617        }
1618        for x, y in self.accounts().items():
1619            if positive_only and y <= 0:
1620                continue
1621            total += float(y)
1622            exchange = self.exchange(x)
1623            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1624        parts['total'] = total
1625        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:
1627    @staticmethod
1628    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1629        """
1630        Checks the validity of payment parts.
1631
1632        Parameters:
1633        parts (dict): A dictionary containing payment parts information.
1634        debug (bool): Flag to enable debug mode.
1635
1636        Returns:
1637        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1638
1639        Error Codes:
1640        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1641        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1642        3: 'part' value in parts['account'][x] is less than 0.
1643        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1644        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1645        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1646        """
1647        if debug:
1648            print('check_payment_parts', f'debug={debug}')
1649        for i in ['demand', 'account', 'total', 'exceed']:
1650            if i not in parts:
1651                return 1
1652        exceed = parts['exceed']
1653        for x in parts['account']:
1654            for j in ['balance', 'rate', 'part']:
1655                if j not in parts['account'][x]:
1656                    return 2
1657                if parts['account'][x]['part'] < 0:
1658                    return 3
1659                if not exceed and parts['account'][x]['balance'] <= 0:
1660                    return 4
1661        demand = parts['demand']
1662        z = 0
1663        for _, y in parts['account'].items():
1664            if not exceed and y['part'] > y['balance']:
1665                return 5
1666            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1667        z = round(z, 2)
1668        demand = round(demand, 2)
1669        if debug:
1670            print('check_payment_parts', f'z = {z}, demand = {demand}')
1671            print('check_payment_parts', type(z), type(demand))
1672            print('check_payment_parts', z != demand)
1673            print('check_payment_parts', str(z) != str(demand))
1674        if z != demand and str(z) != str(demand):
1675            return 6
1676        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:
1678    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1679        """
1680        Perform Zakat calculation based on the given report and optional parts.
1681
1682        Parameters:
1683        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1684        parts (dict): A dictionary containing the payment parts for the zakat.
1685        debug (bool): A flag indicating whether to print debug information.
1686
1687        Returns:
1688        bool: True if the zakat calculation is successful, False otherwise.
1689        """
1690        if debug:
1691            print('zakat', f'debug={debug}')
1692        valid, _, plan = report
1693        if not valid:
1694            return valid
1695        parts_exist = parts is not None
1696        if parts_exist:
1697            if self.check_payment_parts(parts, debug=debug) != 0:
1698                return False
1699        if debug:
1700            print('######### zakat #######')
1701            print('parts_exist', parts_exist)
1702        no_lock = self.nolock()
1703        self.lock()
1704        report_time = self.time()
1705        self._vault['report'][report_time] = report
1706        self._step(Action.REPORT, ref=report_time)
1707        created = self.time()
1708        for x in plan:
1709            target_exchange = self.exchange(x)
1710            if debug:
1711                print(plan[x])
1712                print('-------------')
1713                print(self._vault['account'][x]['box'])
1714            ids = sorted(self._vault['account'][x]['box'].keys())
1715            if debug:
1716                print('plan[x]', plan[x])
1717            for i in plan[x].keys():
1718                j = ids[i]
1719                if debug:
1720                    print('i', i, 'j', j)
1721                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1722                           key='last',
1723                           math_operation=MathOperation.EQUAL)
1724                self._vault['account'][x]['box'][j]['last'] = created
1725                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1726                self._vault['account'][x]['box'][j]['total'] += amount
1727                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1728                           math_operation=MathOperation.ADDITION)
1729                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1730                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1731                           math_operation=MathOperation.ADDITION)
1732                if not parts_exist:
1733                    try:
1734                        self._vault['account'][x]['box'][j]['rest'] -= amount
1735                    except TypeError:
1736                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1737                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1738                    #            math_operation=MathOperation.SUBTRACTION)
1739                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1740        if parts_exist:
1741            for account, part in parts['account'].items():
1742                if part['part'] == 0:
1743                    continue
1744                if debug:
1745                    print('zakat-part', account, part['rate'])
1746                target_exchange = self.exchange(account)
1747                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1748                self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug)
1749        if no_lock:
1750            self.free(self.lock())
1751        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:
1753    def export_json(self, path: str = "data.json") -> bool:
1754        """
1755        Exports the current state of the ZakatTracker object to a JSON file.
1756
1757        Parameters:
1758        path (str): The path where the JSON file will be saved. Default is "data.json".
1759
1760        Returns:
1761        bool: True if the export is successful, False otherwise.
1762
1763        Raises:
1764        No specific exceptions are raised by this method.
1765        """
1766        with open(path, "w") as file:
1767            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1768            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:
1770    def save(self, path: str = None) -> bool:
1771        """
1772        Saves the ZakatTracker's current state to a pickle file.
1773
1774        This method serializes the internal data (`_vault`) along with metadata
1775        (Python version, pickle protocol) for future compatibility.
1776
1777        Parameters:
1778        path (str, optional): File path for saving. Defaults to a predefined location.
1779
1780        Returns:
1781        bool: True if the save operation is successful, False otherwise.
1782        """
1783        if path is None:
1784            path = self.path()
1785        with open(path, "wb") as f:
1786            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1787            pickle_protocol = pickle.HIGHEST_PROTOCOL
1788            data = {
1789                'python_version': version,
1790                'pickle_protocol': pickle_protocol,
1791                'data': self._vault,
1792            }
1793            pickle.dump(data, f, protocol=pickle_protocol)
1794            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:
1796    def load(self, path: str = None) -> bool:
1797        """
1798        Load the current state of the ZakatTracker object from a pickle file.
1799
1800        Parameters:
1801        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1802
1803        Returns:
1804        bool: True if the load operation is successful, False otherwise.
1805        """
1806        if path is None:
1807            path = self.path()
1808        if os.path.exists(path):
1809            with open(path, "rb") as f:
1810                data = pickle.load(f)
1811                self._vault = data['data']
1812                return True
1813        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):
1815    def import_csv_cache_path(self):
1816        """
1817        Generates the cache file path for imported CSV data.
1818
1819        This function constructs the file path where cached data from CSV imports
1820        will be stored. The cache file is a pickle file (.pickle extension) appended
1821        to the base path of the object.
1822
1823        Returns:
1824        str: The full path to the import CSV cache file.
1825
1826        Example:
1827            >>> obj = ZakatTracker('/data/reports')
1828            >>> obj.import_csv_cache_path()
1829            '/data/reports.import_csv.pickle'
1830        """
1831        path = str(self.path())
1832        if path.endswith(".pickle"):
1833            path = path[:-7]
1834        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:
1836    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1837        """
1838        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1839
1840        Parameters:
1841        path (str): The path to the CSV file. Default is 'file.csv'.
1842        debug (bool): A flag indicating whether to print debug information.
1843
1844        Returns:
1845        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1846                and a dictionary of bad transactions.
1847
1848        Notes:
1849            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1850                                        are appropriate for the currency pairs involved in the conversions.
1851            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1852                to 1.0 or the previous rate for that account.
1853            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1854              transactions of the same account within the whole imported and existing dataset when doing `check` and
1855              `zakat` operations.
1856
1857        Example Usage:
1858            The CSV file should have the following format, rate is optional per transaction:
1859            account, desc, value, date, rate
1860            For example:
1861            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1862        """
1863        if debug:
1864            print('import_csv', f'debug={debug}')
1865        cache: list[int] = []
1866        try:
1867            with open(self.import_csv_cache_path(), "rb") as f:
1868                cache = pickle.load(f)
1869        except:
1870            pass
1871        date_formats = [
1872            "%Y-%m-%d %H:%M:%S",
1873            "%Y-%m-%dT%H:%M:%S",
1874            "%Y-%m-%dT%H%M%S",
1875            "%Y-%m-%d",
1876        ]
1877        created, found, bad = 0, 0, {}
1878        data: dict[int, list] = {}
1879        with open(path, newline='', encoding="utf-8") as f:
1880            i = 0
1881            for row in csv.reader(f, delimiter=','):
1882                i += 1
1883                hashed = hash(tuple(row))
1884                if hashed in cache:
1885                    found += 1
1886                    continue
1887                account = row[0]
1888                desc = row[1]
1889                value = float(row[2])
1890                rate = 1.0
1891                if row[4:5]:  # Empty list if index is out of range
1892                    rate = float(row[4])
1893                date: int = 0
1894                for time_format in date_formats:
1895                    try:
1896                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1897                        break
1898                    except:
1899                        pass
1900                # TODO: not allowed for negative dates in the future after enhance time functions
1901                if date == 0 or value == 0:
1902                    bad[i] = row + ('invalid date',)
1903                    continue
1904                if date not in data:
1905                    data[date] = []
1906                data[date].append((i, account, desc, value, date, rate, hashed))
1907
1908        if debug:
1909            print('import_csv', len(data))
1910
1911        if bad:
1912            return created, found, bad
1913
1914        for date, rows in sorted(data.items()):
1915            try:
1916                len_rows = len(rows)
1917                if len_rows == 1:
1918                    (_, account, desc, value, date, rate, hashed) = rows[0]
1919                    if rate > 0:
1920                        self.exchange(account, created=date, rate=rate)
1921                    if value > 0:
1922                        self.track(value, desc, account, True, date)
1923                    elif value < 0:
1924                        self.sub(-value, desc, account, date)
1925                    created += 1
1926                    cache.append(hashed)
1927                    continue
1928                if debug:
1929                    print('-- Duplicated time detected', date, 'len', len_rows)
1930                    print(rows)
1931                    print('---------------------------------')
1932                # If records are found at the same time with different accounts in the same amount
1933                # (one positive and the other negative), this indicates it is a transfer.
1934                if len_rows != 2:
1935                    raise Exception(f'more than two transactions({len_rows}) at the same time')
1936                (i, account1, desc1, value1, date1, rate1, _) = rows[0]
1937                (j, account2, desc2, value2, date2, rate2, _) = rows[1]
1938                if account1 == account2 or desc1 != desc2 or abs(value1) != abs(value2) or date1 != date2:
1939                    raise Exception('invalid transfer')
1940                if rate1 > 0:
1941                    self.exchange(account1, created=date1, rate=rate1)
1942                if rate2 > 0:
1943                    self.exchange(account2, created=date2, rate=rate2)
1944                values = {
1945                    value1: account1,
1946                    value2: account2,
1947                }
1948                self.transfer(
1949                    unscaled_amount=abs(value1),
1950                    from_account=values[min(values.keys())],
1951                    to_account=values[max(values.keys())],
1952                    desc=desc1,
1953                    created=date1,
1954                )
1955            except Exception as e:
1956                for (i, account, desc, value, date, rate, _) in rows:
1957                    bad[i] = (account, desc, value, date, rate, e)
1958                break
1959        with open(self.import_csv_cache_path(), "wb") as file:
1960            pickle.dump(cache, file)
1961        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 human_readable_size(size: float, decimal_places: int = 2) -> str:
1967    @staticmethod
1968    def human_readable_size(size: float, decimal_places: int = 2) -> str:
1969        """
1970        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
1971
1972        This function iterates through progressively larger units of information
1973        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
1974        range that can be expressed with a reasonable number before the unit.
1975
1976        Parameters:
1977        size (float): The size in bytes to convert.
1978        decimal_places (int, optional): The number of decimal places to display
1979            in the result. Defaults to 2.
1980
1981        Returns:
1982        str: A string representation of the size in a human-readable format,
1983            rounded to the specified number of decimal places. For example:
1984                - "1.50 KB" (1536 bytes)
1985                - "23.00 MB" (24117248 bytes)
1986                - "1.23 GB" (1325899906 bytes)
1987        """
1988        if type(size) not in (float, int):
1989            raise TypeError("size must be a float or integer")
1990        if type(decimal_places) != int:
1991            raise TypeError("decimal_places must be an integer")
1992        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
1993            if size < 1024.0:
1994                break
1995            size /= 1024.0
1996        return f"{size:.{decimal_places}f} {unit}"

Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).

This function iterates through progressively larger units of information (B, KB, MB, GB, etc.) and divides the input size until it fits within a range that can be expressed with a reasonable number before the unit.

Parameters: size (float): The size in bytes to convert. decimal_places (int, optional): The number of decimal places to display in the result. Defaults to 2.

Returns: str: A string representation of the size in a human-readable format, rounded to the specified number of decimal places. For example: - "1.50 KB" (1536 bytes) - "23.00 MB" (24117248 bytes) - "1.23 GB" (1325899906 bytes)

@staticmethod
def get_dict_size(obj: dict, seen: set = None) -> float:
1998    @staticmethod
1999    def get_dict_size(obj: dict, seen: set = None) -> float:
2000        """
2001        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
2002
2003        This function traverses the dictionary structure, accounting for the size of keys, values,
2004        and any nested objects. It handles various data types commonly found in dictionaries
2005        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
2006        of circular references.
2007
2008        Parameters:
2009        obj (dict): The dictionary whose size is to be calculated.
2010        seen (set, optional): A set used internally to track visited objects
2011                             and avoid circular references. Defaults to None.
2012
2013        Returns:
2014            float: An approximate size of the dictionary and its contents in bytes.
2015
2016        Note:
2017        - This function is a method of the `ZakatTracker` class and is likely used to
2018          estimate the memory footprint of data structures relevant to Zakat calculations.
2019        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
2020          not account for all memory overhead depending on the Python implementation.
2021        - Circular references are handled to prevent infinite recursion.
2022        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
2023        - String sizes are estimated based on character length and encoding.
2024        """
2025        size = 0
2026        if seen is None:
2027            seen = set()
2028
2029        obj_id = id(obj)
2030        if obj_id in seen:
2031            return 0
2032
2033        seen.add(obj_id)
2034        size += sys.getsizeof(obj)
2035
2036        if isinstance(obj, dict):
2037            for k, v in obj.items():
2038                size += ZakatTracker.get_dict_size(k, seen)
2039                size += ZakatTracker.get_dict_size(v, seen)
2040        elif isinstance(obj, (list, tuple, set, frozenset)):
2041            for item in obj:
2042                size += ZakatTracker.get_dict_size(item, seen)
2043        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2044            pass  # Basic numbers have a fixed size, so nothing to add here
2045        elif isinstance(obj, str):  # Handle strings
2046            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2047        return size

Recursively calculates the approximate memory size of a dictionary and its contents in bytes.

This function traverses the dictionary structure, accounting for the size of keys, values, and any nested objects. It handles various data types commonly found in dictionaries (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case of circular references.

Parameters: obj (dict): The dictionary whose size is to be calculated. seen (set, optional): A set used internally to track visited objects and avoid circular references. Defaults to None.

Returns: float: An approximate size of the dictionary and its contents in bytes.

Note:

  • This function is a method of the ZakatTracker class and is likely used to estimate the memory footprint of data structures relevant to Zakat calculations.
  • The size calculation is approximate as it relies on sys.getsizeof(), which might not account for all memory overhead depending on the Python implementation.
  • Circular references are handled to prevent infinite recursion.
  • Basic numeric types (int, float, complex) are assumed to have fixed sizes.
  • String sizes are estimated based on character length and encoding.
@staticmethod
def duration_from_nanoseconds( ns: int, show_zeros_in_spoken_time: bool = False, spoken_time_separator=',', millennia: str = 'Millennia', century: str = 'Century', years: str = 'Years', days: str = 'Days', hours: str = 'Hours', minutes: str = 'Minutes', seconds: str = 'Seconds', milli_seconds: str = 'MilliSeconds', micro_seconds: str = 'MicroSeconds', nano_seconds: str = 'NanoSeconds') -> tuple:
2049    @staticmethod
2050    def duration_from_nanoseconds(ns: int,
2051                                  show_zeros_in_spoken_time: bool = False,
2052                                  spoken_time_separator=',',
2053                                  millennia: str = 'Millennia',
2054                                  century: str = 'Century',
2055                                  years: str = 'Years',
2056                                  days: str = 'Days',
2057                                  hours: str = 'Hours',
2058                                  minutes: str = 'Minutes',
2059                                  seconds: str = 'Seconds',
2060                                  milli_seconds: str = 'MilliSeconds',
2061                                  micro_seconds: str = 'MicroSeconds',
2062                                  nano_seconds: str = 'NanoSeconds',
2063                                  ) -> tuple:
2064        """
2065        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2066        Convert NanoSeconds to Human Readable Time Format.
2067        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2068        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2069        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2070        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2071
2072        INPUT : ms (AKA: MilliSeconds)
2073        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2074        OUTPUT Variables: time_lapsed, spoken_time
2075
2076        Example  Input: duration_from_nanoseconds(ns)
2077        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
2078        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')
2079        duration_from_nanoseconds(1234567890123456789012)
2080        """
2081        us, ns = divmod(ns, 1000)
2082        ms, us = divmod(us, 1000)
2083        s, ms = divmod(ms, 1000)
2084        m, s = divmod(s, 60)
2085        h, m = divmod(m, 60)
2086        d, h = divmod(h, 24)
2087        y, d = divmod(d, 365)
2088        c, y = divmod(y, 100)
2089        n, c = divmod(c, 10)
2090        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}"
2091        spoken_time_part = []
2092        if n > 0 or show_zeros_in_spoken_time:
2093            spoken_time_part.append(f"{n: 3d} {millennia}")
2094        if c > 0 or show_zeros_in_spoken_time:
2095            spoken_time_part.append(f"{c: 4d} {century}")
2096        if y > 0 or show_zeros_in_spoken_time:
2097            spoken_time_part.append(f"{y: 3d} {years}")
2098        if d > 0 or show_zeros_in_spoken_time:
2099            spoken_time_part.append(f"{d: 4d} {days}")
2100        if h > 0 or show_zeros_in_spoken_time:
2101            spoken_time_part.append(f"{h: 2d} {hours}")
2102        if m > 0 or show_zeros_in_spoken_time:
2103            spoken_time_part.append(f"{m: 2d} {minutes}")
2104        if s > 0 or show_zeros_in_spoken_time:
2105            spoken_time_part.append(f"{s: 2d} {seconds}")
2106        if ms > 0 or show_zeros_in_spoken_time:
2107            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
2108        if us > 0 or show_zeros_in_spoken_time:
2109            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
2110        if ns > 0 or show_zeros_in_spoken_time:
2111            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
2112        return time_lapsed, spoken_time_separator.join(spoken_time_part)

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:
2114    @staticmethod
2115    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2116        """
2117        Convert a specific day, month, and year into a timestamp.
2118
2119        Parameters:
2120        day (int): The day of the month.
2121        month (int): The month of the year. Default is 6 (June).
2122        year (int): The year. Default is 2024.
2123
2124        Returns:
2125        int: The timestamp representing the given day, month, and year.
2126
2127        Note:
2128        This method assumes the default month and year if not provided.
2129        """
2130        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:
2132    @staticmethod
2133    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2134        """
2135        Generate a random date between two given dates.
2136
2137        Parameters:
2138        start_date (datetime.datetime): The start date from which to generate a random date.
2139        end_date (datetime.datetime): The end date until which to generate a random date.
2140
2141        Returns:
2142        datetime.datetime: A random date between the start_date and end_date.
2143        """
2144        time_between_dates = end_date - start_date
2145        days_between_dates = time_between_dates.days
2146        random_number_of_days = random.randrange(days_between_dates)
2147        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:
2149    @staticmethod
2150    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
2151                                 debug: bool = False) -> int:
2152        """
2153        Generate a random CSV file with specified parameters.
2154
2155        Parameters:
2156        path (str): The path where the CSV file will be saved. Default is "data.csv".
2157        count (int): The number of rows to generate in the CSV file. Default is 1000.
2158        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
2159        debug (bool): A flag indicating whether to print debug information.
2160
2161        Returns:
2162        None. The function generates a CSV file at the specified path with the given count of rows.
2163        Each row contains a randomly generated account, description, value, and date.
2164        The value is randomly generated between 1000 and 100000,
2165        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2166        If the row number is not divisible by 13, the value is multiplied by -1.
2167        """
2168        if debug:
2169            print('generate_random_csv_file', f'debug={debug}')
2170        i = 0
2171        with open(path, "w", newline="") as csvfile:
2172            writer = csv.writer(csvfile)
2173            for i in range(count):
2174                account = f"acc-{random.randint(1, 1000)}"
2175                desc = f"Some text {random.randint(1, 1000)}"
2176                value = random.randint(1000, 100000)
2177                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2178                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
2179                if not i % 13 == 0:
2180                    value *= -1
2181                row = [account, desc, value, date]
2182                if with_rate:
2183                    rate = random.randint(1, 100) * 0.12
2184                    if debug:
2185                        print('before-append', row)
2186                    row.append(rate)
2187                    if debug:
2188                        print('after-append', row)
2189                writer.writerow(row)
2190                i = i + 1
2191        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):
2193    @staticmethod
2194    def create_random_list(max_sum, min_value=0, max_value=10):
2195        """
2196        Creates a list of random integers whose sum does not exceed the specified maximum.
2197
2198        Args:
2199            max_sum: The maximum allowed sum of the list elements.
2200            min_value: The minimum possible value for an element (inclusive).
2201            max_value: The maximum possible value for an element (inclusive).
2202
2203        Returns:
2204            A list of random integers.
2205        """
2206        result = []
2207        current_sum = 0
2208
2209        while current_sum < max_sum:
2210            # Calculate the remaining space for the next element
2211            remaining_sum = max_sum - current_sum
2212            # Determine the maximum possible value for the next element
2213            next_max_value = min(remaining_sum, max_value)
2214            # Generate a random element within the allowed range
2215            next_element = random.randint(min_value, next_max_value)
2216            result.append(next_element)
2217            current_sum += next_element
2218
2219        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:
2448    def test(self, debug: bool = False) -> bool:
2449        if debug:
2450            print('test', f'debug={debug}')
2451        try:
2452
2453            self._test_core(True, debug)
2454            self._test_core(False, debug)
2455
2456            assert self._history()
2457
2458            # Not allowed for duplicate transactions in the same account and time
2459
2460            created = ZakatTracker.time()
2461            self.track(100, 'test-1', 'same', True, created)
2462            failed = False
2463            try:
2464                self.track(50, 'test-1', 'same', True, created)
2465            except:
2466                failed = True
2467            assert failed is True
2468
2469            self.reset()
2470
2471            # Same account transfer
2472            for x in [1, 'a', True, 1.8, None]:
2473                failed = False
2474                try:
2475                    self.transfer(1, x, x, 'same-account', debug=debug)
2476                except:
2477                    failed = True
2478                assert failed is True
2479
2480            # Always preserve box age during transfer
2481
2482            series: list[tuple] = [
2483                (30, 4),
2484                (60, 3),
2485                (90, 2),
2486            ]
2487            case = {
2488                3000: {
2489                    'series': series,
2490                    'rest': 15000,
2491                },
2492                6000: {
2493                    'series': series,
2494                    'rest': 12000,
2495                },
2496                9000: {
2497                    'series': series,
2498                    'rest': 9000,
2499                },
2500                18000: {
2501                    'series': series,
2502                    'rest': 0,
2503                },
2504                27000: {
2505                    'series': series,
2506                    'rest': -9000,
2507                },
2508                36000: {
2509                    'series': series,
2510                    'rest': -18000,
2511                },
2512            }
2513
2514            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2515
2516            for total in case:
2517                if debug:
2518                    print('--------------------------------------------------------')
2519                    print(f'case[{total}]', case[total])
2520                for x in case[total]['series']:
2521                    self.track(
2522                        unscaled_value=x[0],
2523                        desc=f"test-{x} ages",
2524                        account='ages',
2525                        logging=True,
2526                        created=selected_time * x[1],
2527                    )
2528
2529                unscaled_total = self.unscale(total)
2530                if debug:
2531                    print('unscaled_total', unscaled_total)
2532                refs = self.transfer(
2533                    unscaled_amount=unscaled_total,
2534                    from_account='ages',
2535                    to_account='future',
2536                    desc='Zakat Movement',
2537                    debug=debug,
2538                )
2539
2540                if debug:
2541                    print('refs', refs)
2542
2543                ages_cache_balance = self.balance('ages')
2544                ages_fresh_balance = self.balance('ages', False)
2545                rest = case[total]['rest']
2546                if debug:
2547                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2548                assert ages_cache_balance == rest
2549                assert ages_fresh_balance == rest
2550
2551                future_cache_balance = self.balance('future')
2552                future_fresh_balance = self.balance('future', False)
2553                if debug:
2554                    print('target', future_cache_balance, future_fresh_balance, total)
2555                    print('refs', refs)
2556                assert future_cache_balance == total
2557                assert future_fresh_balance == total
2558
2559                # TODO: check boxes times for `ages` should equal box times in `future`
2560                for ref in self._vault['account']['ages']['box']:
2561                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2562                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2563                    future_capital = 0
2564                    if ref in self._vault['account']['future']['box']:
2565                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2566                    future_rest = 0
2567                    if ref in self._vault['account']['future']['box']:
2568                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2569                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2570                        if debug:
2571                            print('================================================================')
2572                            print('ages', ages_capital, ages_rest)
2573                            print('future', future_capital, future_rest)
2574                        if ages_rest == 0:
2575                            assert ages_capital == future_capital
2576                        elif ages_rest < 0:
2577                            assert -ages_capital == future_capital
2578                        elif ages_rest > 0:
2579                            assert ages_capital == ages_rest + future_capital
2580                self.reset()
2581                assert len(self._vault['history']) == 0
2582
2583            assert self._history()
2584            assert self._history(False) is False
2585            assert self._history() is False
2586            assert self._history(True)
2587            assert self._history()
2588            if debug:
2589                print('####################################################################')
2590
2591            transaction = [
2592                (
2593                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
2594                    2000, 2000, 2000, 1, 1,
2595                ),
2596                (
2597                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
2598                    75000, 75000, 75000, 1, 1,
2599                ),
2600                (
2601                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
2602                    60000, 60000, 60000, 1, 1,
2603                ),
2604            ]
2605            for z in transaction:
2606                self.lock()
2607                x = z[1]
2608                y = z[2]
2609                self.transfer(
2610                    unscaled_amount=z[0],
2611                    from_account=x,
2612                    to_account=y,
2613                    desc='test-transfer',
2614                    debug=debug,
2615                )
2616                zz = self.balance(x)
2617                if debug:
2618                    print(zz, z)
2619                assert zz == z[3]
2620                xx = self.accounts()[x]
2621                assert xx == z[3]
2622                assert self.balance(x, False) == z[4]
2623                assert xx == z[4]
2624
2625                s = 0
2626                log = self._vault['account'][x]['log']
2627                for i in log:
2628                    s += log[i]['value']
2629                if debug:
2630                    print('s', s, 'z[5]', z[5])
2631                assert s == z[5]
2632
2633                assert self.box_size(x) == z[6]
2634                assert self.log_size(x) == z[7]
2635
2636                yy = self.accounts()[y]
2637                assert self.balance(y) == z[8]
2638                assert yy == z[8]
2639                assert self.balance(y, False) == z[9]
2640                assert yy == z[9]
2641
2642                s = 0
2643                log = self._vault['account'][y]['log']
2644                for i in log:
2645                    s += log[i]['value']
2646                assert s == z[10]
2647
2648                assert self.box_size(y) == z[11]
2649                assert self.log_size(y) == z[12]
2650                assert self.free(self.lock())
2651
2652            if debug:
2653                pp().pprint(self.check(2.17))
2654
2655            assert not self.nolock()
2656            history_count = len(self._vault['history'])
2657            if debug:
2658                print('history-count', history_count)
2659            assert history_count == 4
2660            assert not self.free(ZakatTracker.time())
2661            assert self.free(self.lock())
2662            assert self.nolock()
2663            assert len(self._vault['history']) == 3
2664
2665            # storage
2666
2667            _path = self.path('test.pickle')
2668            if os.path.exists(_path):
2669                os.remove(_path)
2670            self.save()
2671            assert os.path.getsize(_path) > 0
2672            self.reset()
2673            assert self.recall(False, debug) is False
2674            self.load()
2675            assert self._vault['account'] is not None
2676
2677            # recall
2678
2679            assert self.nolock()
2680            assert len(self._vault['history']) == 3
2681            assert self.recall(False, debug) is True
2682            assert len(self._vault['history']) == 2
2683            assert self.recall(False, debug) is True
2684            assert len(self._vault['history']) == 1
2685            assert self.recall(False, debug) is True
2686            assert len(self._vault['history']) == 0
2687            assert self.recall(False, debug) is False
2688            assert len(self._vault['history']) == 0
2689
2690            # exchange
2691
2692            self.exchange("cash", 25, 3.75, "2024-06-25")
2693            self.exchange("cash", 22, 3.73, "2024-06-22")
2694            self.exchange("cash", 15, 3.69, "2024-06-15")
2695            self.exchange("cash", 10, 3.66)
2696
2697            for i in range(1, 30):
2698                exchange = self.exchange("cash", i)
2699                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2700                if debug:
2701                    print(i, rate, description, created)
2702                assert created
2703                if i < 10:
2704                    assert rate == 1
2705                    assert description is None
2706                elif i == 10:
2707                    assert rate == 3.66
2708                    assert description is None
2709                elif i < 15:
2710                    assert rate == 3.66
2711                    assert description is None
2712                elif i == 15:
2713                    assert rate == 3.69
2714                    assert description is not None
2715                elif i < 22:
2716                    assert rate == 3.69
2717                    assert description is not None
2718                elif i == 22:
2719                    assert rate == 3.73
2720                    assert description is not None
2721                elif i >= 25:
2722                    assert rate == 3.75
2723                    assert description is not None
2724                exchange = self.exchange("bank", i)
2725                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2726                if debug:
2727                    print(i, rate, description, created)
2728                assert created
2729                assert rate == 1
2730                assert description is None
2731
2732            assert len(self._vault['exchange']) > 0
2733            assert len(self.exchanges()) > 0
2734            self._vault['exchange'].clear()
2735            assert len(self._vault['exchange']) == 0
2736            assert len(self.exchanges()) == 0
2737
2738            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2739            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2740            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2741            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2742            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2743
2744            for i in [x * 0.12 for x in range(-15, 21)]:
2745                if i <= 0:
2746                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2747                else:
2748                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2749
2750            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2751            for i in range(1, 31):
2752                timestamp_ns = ZakatTracker.day_to_time(i)
2753                exchange = self.exchange("cash", timestamp_ns)
2754                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2755                if debug:
2756                    print(i, rate, description, created)
2757                assert created
2758                if i < 10:
2759                    assert rate == 1
2760                    assert description is None
2761                elif i == 10:
2762                    assert rate == 3.66
2763                    assert description is None
2764                elif i < 15:
2765                    assert rate == 3.66
2766                    assert description is None
2767                elif i == 15:
2768                    assert rate == 3.69
2769                    assert description is not None
2770                elif i < 22:
2771                    assert rate == 3.69
2772                    assert description is not None
2773                elif i == 22:
2774                    assert rate == 3.73
2775                    assert description is not None
2776                elif i >= 25:
2777                    assert rate == 3.75
2778                    assert description is not None
2779                exchange = self.exchange("bank", i)
2780                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2781                if debug:
2782                    print(i, rate, description, created)
2783                assert created
2784                assert rate == 1
2785                assert description is None
2786
2787            # csv
2788
2789            csv_count = 1000
2790
2791            for with_rate, path in {
2792                False: 'test-import_csv-no-exchange',
2793                True: 'test-import_csv-with-exchange',
2794            }.items():
2795
2796                if debug:
2797                    print('test_import_csv', with_rate, path)
2798
2799                csv_path = path + '.csv'
2800                if os.path.exists(csv_path):
2801                    os.remove(csv_path)
2802                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2803                if debug:
2804                    print('generate_random_csv_file', c)
2805                assert c == csv_count
2806                assert os.path.getsize(csv_path) > 0
2807                cache_path = self.import_csv_cache_path()
2808                if os.path.exists(cache_path):
2809                    os.remove(cache_path)
2810                self.reset()
2811                (created, found, bad) = self.import_csv(csv_path, debug)
2812                bad_count = len(bad)
2813                assert bad_count > 0
2814                if debug:
2815                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2816                    print('bad', bad)
2817                tmp_size = os.path.getsize(cache_path)
2818                assert tmp_size > 0
2819                # TODO: assert created + found + bad_count == csv_count
2820                # TODO: assert created == csv_count
2821                # TODO: assert bad_count == 0
2822                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2823                bad_2_count = len(bad_2)
2824                if debug:
2825                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2826                    print('bad', bad)
2827                assert bad_2_count > 0
2828                # TODO: assert tmp_size == os.path.getsize(cache_path)
2829                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
2830                # TODO: assert created == found_2
2831                # TODO: assert bad_count == bad_2_count
2832                # TODO: assert found_2 == csv_count
2833                # TODO: assert bad_2_count == 0
2834                # TODO: assert created_2 == 0
2835
2836                # payment parts
2837
2838                positive_parts = self.build_payment_parts(100, positive_only=True)
2839                assert self.check_payment_parts(positive_parts) != 0
2840                assert self.check_payment_parts(positive_parts) != 0
2841                all_parts = self.build_payment_parts(300, positive_only=False)
2842                assert self.check_payment_parts(all_parts) != 0
2843                assert self.check_payment_parts(all_parts) != 0
2844                if debug:
2845                    pp().pprint(positive_parts)
2846                    pp().pprint(all_parts)
2847                # dynamic discount
2848                suite = []
2849                count = 3
2850                for exceed in [False, True]:
2851                    case = []
2852                    for parts in [positive_parts, all_parts]:
2853                        part = parts.copy()
2854                        demand = part['demand']
2855                        if debug:
2856                            print(demand, part['total'])
2857                        i = 0
2858                        z = demand / count
2859                        cp = {
2860                            'account': {},
2861                            'demand': demand,
2862                            'exceed': exceed,
2863                            'total': part['total'],
2864                        }
2865                        j = ''
2866                        for x, y in part['account'].items():
2867                            x_exchange = self.exchange(x)
2868                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2869                            if exceed and zz <= demand:
2870                                i += 1
2871                                y['part'] = zz
2872                                if debug:
2873                                    print(exceed, y)
2874                                cp['account'][x] = y
2875                                case.append(y)
2876                            elif not exceed and y['balance'] >= zz:
2877                                i += 1
2878                                y['part'] = zz
2879                                if debug:
2880                                    print(exceed, y)
2881                                cp['account'][x] = y
2882                                case.append(y)
2883                            j = x
2884                            if i >= count:
2885                                break
2886                        if len(cp['account'][j]) > 0:
2887                            suite.append(cp)
2888                if debug:
2889                    print('suite', len(suite))
2890                # vault = self._vault.copy()
2891                for case in suite:
2892                    # self._vault = vault.copy()
2893                    if debug:
2894                        print('case', case)
2895                    result = self.check_payment_parts(case)
2896                    if debug:
2897                        print('check_payment_parts', result, f'exceed: {exceed}')
2898                    assert result == 0
2899
2900                    report = self.check(2.17, None, debug)
2901                    (valid, brief, plan) = report
2902                    if debug:
2903                        print('valid', valid)
2904                    zakat_result = self.zakat(report, parts=case, debug=debug)
2905                    if debug:
2906                        print('zakat-result', zakat_result)
2907                    assert valid == zakat_result
2908
2909            assert self.save(path + '.pickle')
2910            assert self.export_json(path + '.json')
2911
2912            assert self.export_json("1000-transactions-test.json")
2913            assert self.save("1000-transactions-test.pickle")
2914
2915            self.reset()
2916
2917            # test transfer between accounts with different exchange rate
2918
2919            a_SAR = "Bank (SAR)"
2920            b_USD = "Bank (USD)"
2921            c_SAR = "Safe (SAR)"
2922            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2923            for case in [
2924                (0, a_SAR, "SAR Gift", 1000, 100000),
2925                (1, a_SAR, 1),
2926                (0, b_USD, "USD Gift", 500, 50000),
2927                (1, b_USD, 1),
2928                (2, b_USD, 3.75),
2929                (1, b_USD, 3.75),
2930                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 40000, 137500),
2931                (0, c_SAR, "Salary", 750, 75000),
2932                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 37500, 50000),
2933                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 137125, 50100),
2934            ]:
2935                if debug:
2936                    print('case', case)
2937                match (case[0]):
2938                    case 0:  # track
2939                        _, account, desc, x, balance = case
2940                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
2941
2942                        cached_value = self.balance(account, cached=True)
2943                        fresh_value = self.balance(account, cached=False)
2944                        if debug:
2945                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2946                        assert cached_value == balance
2947                        assert fresh_value == balance
2948                    case 1:  # check-exchange
2949                        _, account, expected_rate = case
2950                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2951                        if debug:
2952                            print('t-exchange', t_exchange)
2953                        assert t_exchange['rate'] == expected_rate
2954                    case 2:  # do-exchange
2955                        _, account, rate = case
2956                        self.exchange(account, rate=rate, debug=debug)
2957                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2958                        if debug:
2959                            print('b-exchange', b_exchange)
2960                        assert b_exchange['rate'] == rate
2961                    case 3:  # transfer
2962                        _, x, a, b, desc, a_balance, b_balance = case
2963                        self.transfer(x, a, b, desc, debug=debug)
2964
2965                        cached_value = self.balance(a, cached=True)
2966                        fresh_value = self.balance(a, cached=False)
2967                        if debug:
2968                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value, 'a_balance', a_balance)
2969                        assert cached_value == a_balance
2970                        assert fresh_value == a_balance
2971
2972                        cached_value = self.balance(b, cached=True)
2973                        fresh_value = self.balance(b, cached=False)
2974                        if debug:
2975                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2976                        assert cached_value == b_balance
2977                        assert fresh_value == b_balance
2978
2979            # Transfer all in many chunks randomly from B to A
2980            a_SAR_balance = 137125
2981            b_USD_balance = 50100
2982            b_USD_exchange = self.exchange(b_USD)
2983            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
2984            if debug:
2985                print('amounts', amounts)
2986            i = 0
2987            for x in amounts:
2988                if debug:
2989                    print(f'{i} - transfer-with-exchange({x})')
2990                self.transfer(
2991                    unscaled_amount=self.unscale(x),
2992                    from_account=b_USD,
2993                    to_account=a_SAR,
2994                    desc=f"{x} USD -> SAR",
2995                    debug=debug,
2996                )
2997
2998                b_USD_balance -= x
2999                cached_value = self.balance(b_USD, cached=True)
3000                fresh_value = self.balance(b_USD, cached=False)
3001                if debug:
3002                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3003                          b_USD_balance)
3004                assert cached_value == b_USD_balance
3005                assert fresh_value == b_USD_balance
3006
3007                a_SAR_balance += int(x * b_USD_exchange['rate'])
3008                cached_value = self.balance(a_SAR, cached=True)
3009                fresh_value = self.balance(a_SAR, cached=False)
3010                if debug:
3011                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3012                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
3013                assert cached_value == a_SAR_balance
3014                assert fresh_value == a_SAR_balance
3015                i += 1
3016
3017            # Transfer all in many chunks randomly from C to A
3018            c_SAR_balance = 37500
3019            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
3020            if debug:
3021                print('amounts', amounts)
3022            i = 0
3023            for x in amounts:
3024                if debug:
3025                    print(f'{i} - transfer-with-exchange({x})')
3026                self.transfer(
3027                    unscaled_amount=self.unscale(x),
3028                    from_account=c_SAR,
3029                    to_account=a_SAR,
3030                    desc=f"{x} SAR -> a_SAR",
3031                    debug=debug,
3032                )
3033
3034                c_SAR_balance -= x
3035                cached_value = self.balance(c_SAR, cached=True)
3036                fresh_value = self.balance(c_SAR, cached=False)
3037                if debug:
3038                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3039                          c_SAR_balance)
3040                assert cached_value == c_SAR_balance
3041                assert fresh_value == c_SAR_balance
3042
3043                a_SAR_balance += x
3044                cached_value = self.balance(a_SAR, cached=True)
3045                fresh_value = self.balance(a_SAR, cached=False)
3046                if debug:
3047                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3048                          a_SAR_balance)
3049                assert cached_value == a_SAR_balance
3050                assert fresh_value == a_SAR_balance
3051                i += 1
3052
3053            assert self.export_json("accounts-transfer-with-exchange-rates.json")
3054            assert self.save("accounts-transfer-with-exchange-rates.pickle")
3055
3056            # check & zakat with exchange rates for many cycles
3057
3058            for rate, values in {
3059                1: {
3060                    'in': [1000, 2000, 10000],
3061                    'exchanged': [100000, 200000, 1000000],
3062                    'out': [2500, 5000, 73140],
3063                },
3064                3.75: {
3065                    'in': [200, 1000, 5000],
3066                    'exchanged': [75000, 375000, 1875000],
3067                    'out': [1875, 9375, 137138],
3068                },
3069            }.items():
3070                a, b, c = values['in']
3071                m, n, o = values['exchanged']
3072                x, y, z = values['out']
3073                if debug:
3074                    print('rate', rate, 'values', values)
3075                for case in [
3076                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3077                        {'safe': {0: {'below_nisab': x}}},
3078                    ], False, m),
3079                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3080                        {'safe': {0: {'count': 1, 'total': y}}},
3081                    ], True, n),
3082                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
3083                        {'cave': {0: {'count': 3, 'total': z}}},
3084                    ], True, o),
3085                ]:
3086                    if debug:
3087                        print(f"############# check(rate: {rate}) #############")
3088                        print('case', case)
3089                    self.reset()
3090                    self.exchange(account=case[1], created=case[2], rate=rate)
3091                    self.track(unscaled_value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
3092
3093                    # assert self.nolock()
3094                    # history_size = len(self._vault['history'])
3095                    # print('history_size', history_size)
3096                    # assert history_size == 2
3097                    assert self.lock()
3098                    assert not self.nolock()
3099                    report = self.check(2.17, None, debug)
3100                    (valid, brief, plan) = report
3101                    if debug:
3102                        print('brief', brief)
3103                    assert valid == case[4]
3104                    assert case[5] == brief[0]
3105                    assert case[5] == brief[1]
3106
3107                    if debug:
3108                        pp().pprint(plan)
3109
3110                    for x in plan:
3111                        assert case[1] == x
3112                        if 'total' in case[3][0][x][0].keys():
3113                            assert case[3][0][x][0]['total'] == int(brief[2])
3114                            assert int(plan[x][0]['total']) == case[3][0][x][0]['total']
3115                            assert int(plan[x][0]['count']) == case[3][0][x][0]['count']
3116                        else:
3117                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3118                    if debug:
3119                        pp().pprint(report)
3120                    result = self.zakat(report, debug=debug)
3121                    if debug:
3122                        print('zakat-result', result, case[4])
3123                    assert result == case[4]
3124                    report = self.check(2.17, None, debug)
3125                    (valid, brief, plan) = report
3126                    assert valid is False
3127
3128            history_size = len(self._vault['history'])
3129            if debug:
3130                print('history_size', history_size)
3131            assert history_size == 3
3132            assert not self.nolock()
3133            assert self.recall(False, debug) is False
3134            self.free(self.lock())
3135            assert self.nolock()
3136
3137            for i in range(3, 0, -1):
3138                history_size = len(self._vault['history'])
3139                if debug:
3140                    print('history_size', history_size)
3141                assert history_size == i
3142                assert self.recall(False, debug) is True
3143
3144            assert self.nolock()
3145            assert self.recall(False, debug) is False
3146
3147            history_size = len(self._vault['history'])
3148            if debug:
3149                print('history_size', history_size)
3150            assert history_size == 0
3151
3152            account_size = len(self._vault['account'])
3153            if debug:
3154                print('account_size', account_size)
3155            assert account_size == 0
3156
3157            report_size = len(self._vault['report'])
3158            if debug:
3159                print('report_size', report_size)
3160            assert report_size == 0
3161
3162            assert self.nolock()
3163            return True
3164        except:
3165            # pp().pprint(self._vault)
3166            assert self.export_json("test-snapshot.json")
3167            assert self.save("test-snapshot.pickle")
3168            raise
def test(debug: bool = False):
3171def test(debug: bool = False):
3172    ledger = ZakatTracker()
3173    start = ZakatTracker.time()
3174    assert ledger.test(debug=debug)
3175    if debug:
3176        print("#########################")
3177        print("######## TEST DONE ########")
3178        print("#########################")
3179        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
3180        print("#########################")
class Action(enum.Enum):
76class Action(Enum):
77    CREATE = auto()
78    TRACK = auto()
79    LOG = auto()
80    SUB = auto()
81    ADD_FILE = auto()
82    REMOVE_FILE = auto()
83    BOX_TRANSFER = auto()
84    EXCHANGE = auto()
85    REPORT = auto()
86    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):
89class JSONEncoder(json.JSONEncoder):
90    def default(self, obj):
91        if isinstance(obj, Action) or isinstance(obj, MathOperation):
92            return obj.name  # Serialize as the enum member's name
93        elif isinstance(obj, Decimal):
94            return float(obj)
95        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):
90    def default(self, obj):
91        if isinstance(obj, Action) or isinstance(obj, MathOperation):
92            return obj.name  # Serialize as the enum member's name
93        elif isinstance(obj, Decimal):
94            return float(obj)
95        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):
 98class MathOperation(Enum):
 99    ADDITION = auto()
100    EQUAL = auto()
101    SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>
def start_file_server( database_path: str, database_callback: <built-in function callable> = None, csv_callback: <built-in function callable> = None, debug: bool = False) -> tuple:
 55def start_file_server(database_path: str, database_callback: callable = None, csv_callback: callable = None,
 56                      debug: bool = False) -> tuple:
 57    """
 58    Starts a multi-purpose HTTP server to manage file interactions for a Zakat application.
 59
 60    This server facilitates the following functionalities:
 61
 62    1. GET /{file_uuid}/get: Download the database file specified by `database_path`.
 63    2. GET /{file_uuid}/upload: Display an HTML form for uploading files.
 64    3. POST /{file_uuid}/upload: Handle file uploads, distinguishing between:
 65        - Database File (.db): Replaces the existing database with the uploaded one.
 66        - CSV File (.csv): Imports data from the CSV into the existing database.
 67
 68    Args:
 69        database_path (str): The path to the pickle database file.
 70        database_callback (callable, optional): A function to call after a successful database upload.
 71                                                It receives the uploaded database path as its argument.
 72        csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path,
 73                                           the database path, and the debug flag as its arguments.
 74        debug (bool, optional): If True, print debugging information. Defaults to False.
 75
 76    Returns:
 77        Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
 78            - file_name (str): The name of the database file.
 79            - download_url (str): The URL to download the database file.
 80            - upload_url (str): The URL to access the file upload form.
 81            - server_thread (threading.Thread): The thread running the server.
 82            - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
 83
 84    Example:
 85        _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
 86        print(f"Download database: {download_url}")
 87        print(f"Upload files: {upload_url}")
 88        server_thread.start()
 89        # ... later ...
 90        shutdown_server()
 91    """
 92    file_uuid = uuid.uuid4()
 93    file_name = os.path.basename(database_path)
 94
 95    port = find_available_port()
 96    download_url = f"http://localhost:{port}/{file_uuid}/get"
 97    upload_url = f"http://localhost:{port}/{file_uuid}/upload"
 98
 99    class Handler(http.server.SimpleHTTPRequestHandler):
100        def do_GET(self):
101            if self.path == f"/{file_uuid}/get":
102                # GET: Serve the existing file
103                try:
104                    with open(database_path, "rb") as f:
105                        self.send_response(200)
106                        self.send_header("Content-type", "application/octet-stream")
107                        self.send_header("Content-Disposition", f'attachment; filename="{file_name}"')
108                        self.end_headers()
109                        self.wfile.write(f.read())
110                except FileNotFoundError:
111                    self.send_error(404, "File not found")
112            elif self.path == f"/{file_uuid}/upload":
113                # GET: Serve the upload form
114                self.send_response(200)
115                self.send_header("Content-type", "text/html")
116                self.end_headers()
117                self.wfile.write(f"""
118                    <html lang="en">
119                        <head>
120                            <title>Zakat File Server</title>
121                        </head>
122                    <body>
123                    <h1>Zakat File Server</h1>
124                    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
125                    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
126                    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
127                        <input type="file" name="file" required><br/>
128                        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
129                        <label for="database">Database File</label><br/>
130                        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
131                        <label for="csv">CSV File</label><br/>
132                        <input type="submit" value="Upload"><br/>
133                    </form>
134                    </body></html>
135                """.encode())
136            else:
137                self.send_error(404)
138
139        def do_POST(self):
140            if self.path == f"/{file_uuid}/upload":
141                # POST: Handle request
142                # 1. Get the Form Data
143                form_data = cgi.FieldStorage(
144                    fp=self.rfile,
145                    headers=self.headers,
146                    environ={'REQUEST_METHOD': 'POST'}
147                )
148                upload_type = form_data.getvalue("upload_type")
149
150                if debug:
151                    print('upload_type', upload_type)
152
153                if upload_type not in [FileType.Database.value, FileType.CSV.value]:
154                    self.send_error(400, "Invalid upload type")
155                    return
156
157                # 2. Extract File Data
158                file_item = form_data['file']  # Assuming 'file' is your file input name
159
160                # 3. Get File Details
161                filename = file_item.filename
162                file_data = file_item.file.read()  # Read the file's content
163
164                if debug:
165                    print(f'Uploaded filename: {filename}')
166
167                # 4. Define Storage Path for CSV
168                upload_directory = "./uploads"  # Create this directory if it doesn't exist
169                os.makedirs(upload_directory, exist_ok=True)
170                file_path = os.path.join(upload_directory, upload_type)
171
172                # 5. Write to Disk
173                with open(file_path, 'wb') as f:
174                    f.write(file_data)
175
176                match upload_type:
177                    case FileType.Database.value:
178
179                        try:
180                            # 6. Verify database file
181                            # ZakatTracker(db_path=file_path) # FATAL, Circular Imports Error
182                            if database_callback is not None:
183                                database_callback(file_path)
184
185                            # 7. Copy database into the original path
186                            shutil.copy2(file_path, database_path)
187                        except Exception as e:
188                            self.send_error(400, str(e))
189                            return
190
191                    case FileType.CSV.value:
192                        # 6. Verify CSV file
193                        try:
194                            # x = ZakatTracker(db_path=database_path) # FATAL, Circular Imports Error
195                            # result = x.import_csv(file_path, debug=debug)
196                            if csv_callback is not None:
197                                result = csv_callback(file_path, database_path, debug)
198                                if debug:
199                                    print(f'CSV imported: {result}')
200                                if len(result[2]) != 0:
201                                    self.send_response(200)
202                                    self.end_headers()
203                                    self.wfile.write(json.dumps(result).encode())
204                                    return
205                        except Exception as e:
206                            self.send_error(400, str(e))
207                            return
208
209                self.send_response(200)
210                self.end_headers()
211                self.wfile.write(b"File uploaded successfully.")
212
213    httpd = socketserver.TCPServer(("localhost", port), Handler)
214    server_thread = threading.Thread(target=httpd.serve_forever)
215
216    def shutdown_server():
217        nonlocal httpd, server_thread
218        httpd.shutdown()
219        httpd.server_close()  # Close the socket
220        server_thread.join()  # Wait for the thread to finish
221
222    return file_name, download_url, upload_url, server_thread, shutdown_server

Starts a multi-purpose HTTP server to manage file interactions for a Zakat application.

This server facilitates the following functionalities:

  1. GET /{file_uuid}/get: Download the database file specified by database_path.
  2. GET /{file_uuid}/upload: Display an HTML form for uploading files.
  3. POST /{file_uuid}/upload: Handle file uploads, distinguishing between:
    • Database File (.db): Replaces the existing database with the uploaded one.
    • CSV File (.csv): Imports data from the CSV into the existing database.

Args: database_path (str): The path to the pickle database file. database_callback (callable, optional): A function to call after a successful database upload. It receives the uploaded database path as its argument. csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path, the database path, and the debug flag as its arguments. debug (bool, optional): If True, print debugging information. Defaults to False.

Returns: Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing: - file_name (str): The name of the database file. - download_url (str): The URL to download the database file. - upload_url (str): The URL to access the file upload form. - server_thread (threading.Thread): The thread running the server. - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.

Example: _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db") print(f"Download database: {download_url}") print(f"Upload files: {upload_url}") server_thread.start() # ... later ... shutdown_server()

def find_available_port() -> int:
32def find_available_port() -> int:
33    """
34    Finds and returns an available TCP port on the local machine.
35
36    This function utilizes a TCP server socket to bind to port 0, which
37    instructs the operating system to automatically assign an available
38    port. The assigned port is then extracted and returned.
39
40    Returns:
41        int: The available TCP port number.
42
43    Raises:
44        OSError: If an error occurs during the port binding process, such
45            as all ports being in use.
46
47    Example:
48        port = find_available_port()
49        print(f"Available port: {port}")
50    """
51    with socketserver.TCPServer(("localhost", 0), None) as s:
52        return s.server_address[1]

Finds and returns an available TCP port on the local machine.

This function utilizes a TCP server socket to bind to port 0, which instructs the operating system to automatically assign an available port. The assigned port is then extracted and returned.

Returns: int: The available TCP port number.

Raises: OSError: If an error occurs during the port binding process, such as all ports being in use.

Example: port = find_available_port() print(f"Available port: {port}")

class FileType(enum.Enum):
13class FileType(Enum):
14    Database = 'db'
15    CSV = 'csv'
Database = <FileType.Database: 'db'>
CSV = <FileType.CSV: 'csv'>