zakat.zakat_tracker
xxx

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

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

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

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

Key Features:

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

The module also includes a few helper functions and classes:

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

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

Example usage:

from zakat_tracker import ZakatTracker

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

In this file docstring:

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

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

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

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:
186    @staticmethod
187    def ZakatCut(x: float) -> float:
188        """
189        Calculates the Zakat amount due on an asset.
190
191        This function calculates the zakat amount due on a given asset value over one lunar year.
192        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
193        that exceeds a certain threshold (Nisab).
194
195        Parameters:
196        x: The total value of the asset on which Zakat is to be calculated.
197
198        Returns:
199        The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
200        """
201        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:
203    @staticmethod
204    def TimeCycle(days: int = 355) -> int:
205        """
206        Calculates the approximate duration of a lunar year in nanoseconds.
207
208        This function calculates the approximate duration of a lunar year based on the given number of days.
209        It converts the given number of days into nanoseconds for use in high-precision timing applications.
210
211        Parameters:
212        days: The number of days in a lunar year. Defaults to 355,
213              which is an approximation of the average length of a lunar year.
214
215        Returns:
216        The approximate duration of a lunar year in nanoseconds.
217        """
218        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:
220    @staticmethod
221    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
222        """
223        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
224
225        This function calculates the Nisab value, which is the minimum threshold of wealth,
226        that makes an individual liable for paying Zakat.
227        The Nisab value is determined by the equivalent value of a specific amount
228        of gold or silver (currently 595 grams in silver) in the local currency.
229
230        Parameters:
231        - gram_price (float): The price per gram of Nisab.
232        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
233
234        Returns:
235        - float: The total value of Nisab based on the given price per gram.
236        """
237        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:
257    def path(self, path: str = None) -> str:
258        """
259        Set or get the database path.
260
261        Parameters:
262        path (str): The path to the database file. If not provided, it returns the current path.
263
264        Returns:
265        str: The current database path.
266        """
267        if path is not None:
268            self._vault_path = path
269        return self._vault_path

Set or get the database path.

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

Returns: str: The current database path.

@staticmethod
def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
271    @staticmethod
272    def scale(x: float | int | Decimal, decimal_places: int = 2) -> int:
273        """
274        Scales a numerical value by a specified power of 10, returning an integer.
275
276        This function is designed to handle various numeric types (`float`, `int`, or `Decimal`) and
277        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
278
279        Parameters:
280        x: The numeric value to scale. Can be a floating-point number, integer, or decimal.
281        decimal_places: The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
282            by a factor of 100 (e.g., converts 1.23 to 123).
283
284        Returns:
285        The scaled value, rounded to the nearest integer.
286
287        Raises:
288        TypeError: If the input `x` is not a valid numeric type.
289
290        Examples:
291        >>> scale(3.14159)
292        314
293        >>> scale(1234, decimal_places=3)
294        1234000
295        >>> scale(Decimal("0.005"), decimal_places=4)
296        50
297        """
298        if not isinstance(x, (float, int, Decimal)):
299            raise TypeError("Input 'x' must be a float, int, or Decimal.")
300        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:

>>> scale(3.14159)
314
>>> scale(1234, decimal_places=3)
1234000
>>> 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:
302    @staticmethod
303    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | Decimal:
304        """
305        Unscales an integer by a power of 10.
306
307        Parameters:
308        x: The integer to unscale.
309        return_type: The desired type for the returned value. Can be float, int, or Decimal. Defaults to float.
310        decimal_places: The power of 10 to use. Defaults to 2.
311
312        Returns:
313        The unscaled number, converted to the specified return_type.
314
315        Raises:
316        TypeError: If the return_type is not float or Decimal.
317        """
318        if return_type not in (float, Decimal):
319            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and Decimal.')
320        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:
336    def reset(self) -> None:
337        """
338        Reset the internal data structure to its initial state.
339
340        Parameters:
341        None
342
343        Returns:
344        None
345        """
346        self._vault = {
347            'account': {},
348            'exchange': {},
349            'history': {},
350            'lock': None,
351            'report': {},
352        }

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:
354    @staticmethod
355    def time(now: datetime = None) -> int:
356        """
357        Generates a timestamp based on the provided datetime object or the current datetime.
358
359        Parameters:
360        now (datetime, optional): The datetime object to generate the timestamp from.
361        If not provided, the current datetime is used.
362
363        Returns:
364        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
365            before 1970 will return in negative until 1000AD.
366        """
367        if now is None:
368            now = datetime.datetime.now()
369        ordinal_day = now.toordinal()
370        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
371        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'>:
373    @staticmethod
374    def time_to_datetime(ordinal_ns: int) -> datetime:
375        """
376        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
377
378        Parameters:
379        ordinal_ns (int): The ordinal number of days since 1000-01-01.
380
381        Returns:
382        datetime: The corresponding datetime object.
383        """
384        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
385        ns_in_day = ordinal_ns % 86_400_000_000_000
386        d = datetime.datetime.fromordinal(ordinal_day)
387        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
388        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:
390    def clean_history(self, lock: int | None = None) -> int:
391        """
392        Cleans up the history of actions performed on the ZakatTracker instance.
393
394        Parameters:
395        lock (int, optional): The lock ID is used to clean up the empty history.
396            If not provided, it cleans up the empty history records for all locks.
397
398        Returns:
399        int: The number of locks cleaned up.
400        """
401        count = 0
402        if lock in self._vault['history']:
403            if len(self._vault['history'][lock]) <= 0:
404                count += 1
405                del self._vault['history'][lock]
406            return count
407        self.free(self.lock())
408        for lock in self._vault['history']:
409            if len(self._vault['history'][lock]) <= 0:
410                count += 1
411                del self._vault['history'][lock]
412        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:
450    def nolock(self) -> bool:
451        """
452        Check if the vault lock is currently not set.
453
454        Returns:
455        bool: True if the vault lock is not set, False otherwise.
456        """
457        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:
459    def lock(self) -> int:
460        """
461        Acquires a lock on the ZakatTracker instance.
462
463        Returns:
464        int: The lock ID. This ID can be used to release the lock later.
465        """
466        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:
468    def vault(self) -> dict:
469        """
470        Returns a copy of the internal vault dictionary.
471
472        This method is used to retrieve the current state of the ZakatTracker object.
473        It provides a snapshot of the internal data structure, allowing for further
474        processing or analysis.
475
476        Returns:
477        dict: A copy of the internal vault dictionary.
478        """
479        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]:
481    def stats(self) -> dict[str, tuple]:
482        """
483        Calculates and returns statistics about the object's data storage.
484
485        This method determines the size of the database file on disk and the
486        size of the data currently held in RAM (likely within a dictionary).
487        Both sizes are reported in bytes and in a human-readable format
488        (e.g., KB, MB).
489
490        Returns:
491        dict[str, tuple]: A dictionary containing the following statistics:
492
493            * 'database': A tuple with two elements:
494                - The database file size in bytes (int).
495                - The database file size in human-readable format (str).
496            * 'ram': A tuple with two elements:
497                - The RAM usage (dictionary size) in bytes (int).
498                - The RAM usage in human-readable format (str).
499
500        Example:
501        >>> stats = my_object.stats()
502        >>> print(stats['database'])
503        (256000, '250.0 KB')
504        >>> print(stats['ram'])
505        (12345, '12.1 KB')
506        """
507        ram_size = self.get_dict_size(self.vault())
508        file_size = os.path.getsize(self.path())
509        return {
510            'database': (file_size, self.human_readable_size(file_size)),
511            'ram': (ram_size, self.human_readable_size(ram_size)),
512        }

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 steps(self) -> dict:
514    def steps(self) -> dict:
515        """
516        Returns a copy of the history of steps taken in the ZakatTracker.
517
518        The history is a dictionary where each key is a unique identifier for a step,
519        and the corresponding value is a dictionary containing information about the step.
520
521        Returns:
522        dict: A copy of the history of steps taken in the ZakatTracker.
523        """
524        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:
526    def free(self, lock: int, auto_save: bool = True) -> bool:
527        """
528        Releases the lock on the database.
529
530        Parameters:
531        lock (int): The lock ID to be released.
532        auto_save (bool): Whether to automatically save the database after releasing the lock.
533
534        Returns:
535        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
536        """
537        if lock == self._vault['lock']:
538            self._vault['lock'] = None
539            self.clean_history(lock)
540            if auto_save:
541                return self.save(self.path())
542            return True
543        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:
545    def account_exists(self, account) -> bool:
546        """
547        Check if the given account exists in the vault.
548
549        Parameters:
550        account (str): The account number to check.
551
552        Returns:
553        bool: True if the account exists, False otherwise.
554        """
555        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:
557    def box_size(self, account) -> int:
558        """
559        Calculate the size of the box for a specific account.
560
561        Parameters:
562        account (str): The account number for which the box size needs to be calculated.
563
564        Returns:
565        int: The size of the box for the given account. If the account does not exist, -1 is returned.
566        """
567        if self.account_exists(account):
568            return len(self._vault['account'][account]['box'])
569        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:
571    def log_size(self, account) -> int:
572        """
573        Get the size of the log for a specific account.
574
575        Parameters:
576        account (str): The account number for which the log size needs to be calculated.
577
578        Returns:
579        int: The size of the log for the given account. If the account does not exist, -1 is returned.
580        """
581        if self.account_exists(account):
582            return len(self._vault['account'][account]['log'])
583        return -1

Get the size of the log for a specific account.

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

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

def recall(self, dry=True, debug=False) -> bool:
585    def recall(self, dry=True, debug=False) -> bool:
586        """
587        Revert the last operation.
588
589        Parameters:
590        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
591        debug (bool): If True, the function will print debug information. Default is False.
592
593        Returns:
594        bool: True if the operation was successful, False otherwise.
595        """
596        if not self.nolock() or len(self._vault['history']) == 0:
597            return False
598        if len(self._vault['history']) <= 0:
599            return False
600        ref = sorted(self._vault['history'].keys())[-1]
601        if debug:
602            print('recall', ref)
603        memory = self._vault['history'][ref]
604        if debug:
605            print(type(memory), 'memory', memory)
606
607        limit = len(memory) + 1
608        sub_positive_log_negative = 0
609        for i in range(-1, -limit, -1):
610            x = memory[i]
611            if debug:
612                print(type(x), x)
613            match x['action']:
614                case Action.CREATE:
615                    if x['account'] is not None:
616                        if self.account_exists(x['account']):
617                            if debug:
618                                print('account', self._vault['account'][x['account']])
619                            assert len(self._vault['account'][x['account']]['box']) == 0
620                            assert self._vault['account'][x['account']]['balance'] == 0
621                            assert self._vault['account'][x['account']]['count'] == 0
622                            if dry:
623                                continue
624                            del self._vault['account'][x['account']]
625
626                case Action.TRACK:
627                    if x['account'] is not None:
628                        if self.account_exists(x['account']):
629                            if dry:
630                                continue
631                            self._vault['account'][x['account']]['balance'] -= x['value']
632                            self._vault['account'][x['account']]['count'] -= 1
633                            del self._vault['account'][x['account']]['box'][x['ref']]
634
635                case Action.LOG:
636                    if x['account'] is not None:
637                        if self.account_exists(x['account']):
638                            if x['ref'] in self._vault['account'][x['account']]['log']:
639                                if dry:
640                                    continue
641                                if sub_positive_log_negative == -x['value']:
642                                    self._vault['account'][x['account']]['count'] -= 1
643                                    sub_positive_log_negative = 0
644                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
645                                if not box_ref is None:
646                                    assert self.box_exists(x['account'], box_ref)
647                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
648                                    assert box_value < 0
649                                    self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
650                                    self._vault['account'][x['account']]['balance'] += -box_value
651                                    self._vault['account'][x['account']]['count'] -= 1
652                                del self._vault['account'][x['account']]['log'][x['ref']]
653
654                case Action.SUB:
655                    if x['account'] is not None:
656                        if self.account_exists(x['account']):
657                            if x['ref'] in self._vault['account'][x['account']]['box']:
658                                if dry:
659                                    continue
660                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
661                                self._vault['account'][x['account']]['balance'] += x['value']
662                                sub_positive_log_negative = x['value']
663
664                case Action.ADD_FILE:
665                    if x['account'] is not None:
666                        if self.account_exists(x['account']):
667                            if x['ref'] in self._vault['account'][x['account']]['log']:
668                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
669                                    if dry:
670                                        continue
671                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
672
673                case Action.REMOVE_FILE:
674                    if x['account'] is not None:
675                        if self.account_exists(x['account']):
676                            if x['ref'] in self._vault['account'][x['account']]['log']:
677                                if dry:
678                                    continue
679                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
680
681                case Action.BOX_TRANSFER:
682                    if x['account'] is not None:
683                        if self.account_exists(x['account']):
684                            if x['ref'] in self._vault['account'][x['account']]['box']:
685                                if dry:
686                                    continue
687                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
688
689                case Action.EXCHANGE:
690                    if x['account'] is not None:
691                        if x['account'] in self._vault['exchange']:
692                            if x['ref'] in self._vault['exchange'][x['account']]:
693                                if dry:
694                                    continue
695                                del self._vault['exchange'][x['account']][x['ref']]
696
697                case Action.REPORT:
698                    if x['ref'] in self._vault['report']:
699                        if dry:
700                            continue
701                        del self._vault['report'][x['ref']]
702
703                case Action.ZAKAT:
704                    if x['account'] is not None:
705                        if self.account_exists(x['account']):
706                            if x['ref'] in self._vault['account'][x['account']]['box']:
707                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
708                                    if dry:
709                                        continue
710                                    match x['math']:
711                                        case MathOperation.ADDITION:
712                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
713                                                'value']
714                                        case MathOperation.EQUAL:
715                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
716                                        case MathOperation.SUBTRACTION:
717                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
718                                                'value']
719
720        if not dry:
721            del self._vault['history'][ref]
722        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:
724    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
725        """
726        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
727
728        Parameters:
729        account (str): The account number for which to check the existence of the reference.
730        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
731        ref (int): The reference (transaction) number to check for existence.
732
733        Returns:
734        bool: True if the reference exists for the given account and reference type, False otherwise.
735        """
736        if account in self._vault['account']:
737            return ref in self._vault['account'][account][ref_type]
738        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:
740    def box_exists(self, account: str, ref: int) -> bool:
741        """
742        Check if a specific box (transaction) exists in the vault for a given account and reference.
743
744        Parameters:
745        - account (str): The account number for which to check the existence of the box.
746        - ref (int): The reference (transaction) number to check for existence.
747
748        Returns:
749        - bool: True if the box exists for the given account and reference, False otherwise.
750        """
751        return self.ref_exists(account, 'box', ref)

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

Parameters:

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

Returns:

  • bool: True if the box exists for the given account and reference, False otherwise.
def track( self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
753    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
754              debug: bool = False) -> int:
755        """
756        This function tracks a transaction for a specific account.
757
758        Parameters:
759        value (float): The value of the transaction. Default is 0.
760        desc (str): The description of the transaction. Default is an empty string.
761        account (str): The account for which the transaction is being tracked. Default is '1'.
762        logging (bool): Whether to log the transaction. Default is True.
763        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
764        debug (bool): Whether to print debug information. Default is False.
765
766        Returns:
767        int: The timestamp of the transaction.
768
769        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.
770
771        Raises:
772        ValueError: The log transaction happened again in the same nanosecond time.
773        ValueError: The box transaction happened again in the same nanosecond time.
774        """
775        if debug:
776            print('track', f'debug={debug}')
777        if created is None:
778            created = self.time()
779        no_lock = self.nolock()
780        self.lock()
781        if not self.account_exists(account):
782            if debug:
783                print(f"account {account} created")
784            self._vault['account'][account] = {
785                'balance': 0,
786                'box': {},
787                'count': 0,
788                'log': {},
789                'hide': False,
790                'zakatable': True,
791            }
792            self._step(Action.CREATE, account)
793        if value == 0:
794            if no_lock:
795                self.free(self.lock())
796            return 0
797        if logging:
798            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
799        if debug:
800            print('create-box', created)
801        if self.box_exists(account, created):
802            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
803        if debug:
804            print('created-box', created)
805        self._vault['account'][account]['box'][created] = {
806            'capital': value,
807            'count': 0,
808            'last': 0,
809            'rest': value,
810            'total': 0,
811        }
812        self._step(Action.TRACK, account, ref=created, value=value)
813        if no_lock:
814            self.free(self.lock())
815        return created

This function tracks a transaction for a specific account.

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

Returns: int: The timestamp of the transaction.

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

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

def log_exists(self, account: str, ref: int) -> bool:
817    def log_exists(self, account: str, ref: int) -> bool:
818        """
819        Checks if a specific transaction log entry exists for a given account.
820
821        Parameters:
822        account (str): The account number associated with the transaction log.
823        ref (int): The reference to the transaction log entry.
824
825        Returns:
826        bool: True if the transaction log entry exists, False otherwise.
827        """
828        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:
874    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
875                 debug: bool = False) -> dict:
876        """
877        This method is used to record or retrieve exchange rates for a specific account.
878
879        Parameters:
880        - account (str): The account number for which the exchange rate is being recorded or retrieved.
881        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
882        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
883        - description (str): A description of the exchange rate.
884
885        Returns:
886        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
887        it returns a dictionary with default values for the rate and description.
888        """
889        if debug:
890            print('exchange', f'debug={debug}')
891        if created is None:
892            created = self.time()
893        no_lock = self.nolock()
894        self.lock()
895        if rate is not None:
896            if rate <= 0:
897                return dict()
898            if account not in self._vault['exchange']:
899                self._vault['exchange'][account] = {}
900            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
901                return {"time": created, "rate": 1, "description": None}
902            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
903            self._step(Action.EXCHANGE, account, ref=created, value=rate)
904            if no_lock:
905                self.free(self.lock())
906            if debug:
907                print("exchange-created-1",
908                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
909
910        if account in self._vault['exchange']:
911            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
912            if valid_rates:
913                latest_rate = max(valid_rates, key=lambda x: x[0])
914                if debug:
915                    print("exchange-read-1",
916                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
917                          'latest_rate', latest_rate)
918                result = latest_rate[1]
919                result['time'] = latest_rate[0]
920                return result  # إرجاع قاموس يحتوي على المعدل والوصف
921        if debug:
922            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
923        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:
925    @staticmethod
926    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
927        """
928        This function calculates the exchanged amount of a currency.
929
930        Args:
931            x (float): The original amount of the currency.
932            x_rate (float): The exchange rate of the original currency.
933            y_rate (float): The exchange rate of the target currency.
934
935        Returns:
936            float: The exchanged amount of the target currency.
937        """
938        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:
940    def exchanges(self) -> dict:
941        """
942        Retrieve the recorded exchange rates for all accounts.
943
944        Parameters:
945        None
946
947        Returns:
948        dict: A dictionary containing all recorded exchange rates.
949        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
950        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
951        """
952        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:
954    def accounts(self) -> dict:
955        """
956        Returns a dictionary containing account numbers as keys and their respective balances as values.
957
958        Parameters:
959        None
960
961        Returns:
962        dict: A dictionary where keys are account numbers and values are their respective balances.
963        """
964        result = {}
965        for i in self._vault['account']:
966            result[i] = self._vault['account'][i]['balance']
967        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:
969    def boxes(self, account) -> dict:
970        """
971        Retrieve the boxes (transactions) associated with a specific account.
972
973        Parameters:
974        account (str): The account number for which to retrieve the boxes.
975
976        Returns:
977        dict: A dictionary containing the boxes associated with the given account.
978        If the account does not exist, an empty dictionary is returned.
979        """
980        if self.account_exists(account):
981            return self._vault['account'][account]['box']
982        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:
984    def logs(self, account) -> dict:
985        """
986        Retrieve the logs (transactions) associated with a specific account.
987
988        Parameters:
989        account (str): The account number for which to retrieve the logs.
990
991        Returns:
992        dict: A dictionary containing the logs associated with the given account.
993        If the account does not exist, an empty dictionary is returned.
994        """
995        if self.account_exists(account):
996            return self._vault['account'][account]['log']
997        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:
 999    def add_file(self, account: str, ref: int, path: str) -> int:
1000        """
1001        Adds a file reference to a specific transaction log entry in the vault.
1002
1003        Parameters:
1004        account (str): The account number associated with the transaction log.
1005        ref (int): The reference to the transaction log entry.
1006        path (str): The path of the file to be added.
1007
1008        Returns:
1009        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1010        """
1011        if self.account_exists(account):
1012            if ref in self._vault['account'][account]['log']:
1013                file_ref = self.time()
1014                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1015                no_lock = self.nolock()
1016                self.lock()
1017                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1018                if no_lock:
1019                    self.free(self.lock())
1020                return file_ref
1021        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:
1023    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1024        """
1025        Removes a file reference from a specific transaction log entry in the vault.
1026
1027        Parameters:
1028        account (str): The account number associated with the transaction log.
1029        ref (int): The reference to the transaction log entry.
1030        file_ref (int): The reference of the file to be removed.
1031
1032        Returns:
1033        bool: True if the file reference is successfully removed, False otherwise.
1034        """
1035        if self.account_exists(account):
1036            if ref in self._vault['account'][account]['log']:
1037                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1038                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1039                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1040                    no_lock = self.nolock()
1041                    self.lock()
1042                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1043                    if no_lock:
1044                        self.free(self.lock())
1045                    return True
1046        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:
1048    def balance(self, account: str = 1, cached: bool = True) -> int:
1049        """
1050        Calculate and return the balance of a specific account.
1051
1052        Parameters:
1053        account (str): The account number. Default is '1'.
1054        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1055
1056        Returns:
1057        int: The balance of the account.
1058
1059        Note:
1060        If cached is True, the function returns the cached balance.
1061        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1062        """
1063        if cached:
1064            return self._vault['account'][account]['balance']
1065        x = 0
1066        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:
1068    def hide(self, account, status: bool = None) -> bool:
1069        """
1070        Check or set the hide status of a specific account.
1071
1072        Parameters:
1073        account (str): The account number.
1074        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1075
1076        Returns:
1077        bool: The current or updated hide status of the account.
1078
1079        Raises:
1080        None
1081
1082        Example:
1083        >>> tracker = ZakatTracker()
1084        >>> ref = tracker.track(51, 'desc', 'account1')
1085        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1086        False
1087        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1088        True
1089        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1090        True
1091        >>> tracker.hide('account1', False)
1092        False
1093        """
1094        if self.account_exists(account):
1095            if status is None:
1096                return self._vault['account'][account]['hide']
1097            self._vault['account'][account]['hide'] = status
1098            return status
1099        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:
1101    def zakatable(self, account, status: bool = None) -> bool:
1102        """
1103        Check or set the zakatable status of a specific account.
1104
1105        Parameters:
1106        account (str): The account number.
1107        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1108
1109        Returns:
1110        bool: The current or updated zakatable status of the account.
1111
1112        Raises:
1113        None
1114
1115        Example:
1116        >>> tracker = ZakatTracker()
1117        >>> ref = tracker.track(51, 'desc', 'account1')
1118        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1119        True
1120        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1121        True
1122        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1123        True
1124        >>> tracker.zakatable('account1', False)
1125        False
1126        """
1127        if self.account_exists(account):
1128            if status is None:
1129                return self._vault['account'][account]['zakatable']
1130            self._vault['account'][account]['zakatable'] = status
1131            return status
1132        return False

Check or set the zakatable status of a specific account.

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

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

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
True
>>> tracker.zakatable('account1', False)
False
def sub( self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1134    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1135        """
1136        Subtracts a specified value from an account's balance.
1137
1138        Parameters:
1139        x (float): The amount to be subtracted.
1140        desc (str): A description for the transaction. Defaults to an empty string.
1141        account (str): The account from which the value will be subtracted. Defaults to '1'.
1142        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1143        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1144
1145        Returns:
1146        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1147
1148        If the amount to subtract is greater than the account's balance,
1149        the remaining amount will be transferred to a new transaction with a negative value.
1150
1151        Raises:
1152        ValueError: The box transaction happened again in the same nanosecond time.
1153        ValueError: The log transaction happened again in the same nanosecond time.
1154        """
1155        if debug:
1156            print('sub', f'debug={debug}')
1157        if x < 0:
1158            return tuple()
1159        if x == 0:
1160            ref = self.track(x, '', account)
1161            return ref, ref
1162        if created is None:
1163            created = self.time()
1164        no_lock = self.nolock()
1165        self.lock()
1166        self.track(0, '', account)
1167        self._log(value=-x, desc=desc, account=account, created=created, ref=None, debug=debug)
1168        ids = sorted(self._vault['account'][account]['box'].keys())
1169        limit = len(ids) + 1
1170        target = x
1171        if debug:
1172            print('ids', ids)
1173        ages = []
1174        for i in range(-1, -limit, -1):
1175            if target == 0:
1176                break
1177            j = ids[i]
1178            if debug:
1179                print('i', i, 'j', j)
1180            rest = self._vault['account'][account]['box'][j]['rest']
1181            if rest >= target:
1182                self._vault['account'][account]['box'][j]['rest'] -= target
1183                self._step(Action.SUB, account, ref=j, value=target)
1184                ages.append((j, target))
1185                target = 0
1186                break
1187            elif target > rest > 0:
1188                chunk = rest
1189                target -= chunk
1190                self._step(Action.SUB, account, ref=j, value=chunk)
1191                ages.append((j, chunk))
1192                self._vault['account'][account]['box'][j]['rest'] = 0
1193        if target > 0:
1194            self.track(-target, desc, account, False, created)
1195            ages.append((created, target))
1196        if no_lock:
1197            self.free(self.lock())
1198        return created, ages

Subtracts a specified value from an account's balance.

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

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

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

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

def transfer( self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
1200    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
1201                 debug: bool = False) -> list[int]:
1202        """
1203        Transfers a specified value from one account to another.
1204
1205        Parameters:
1206        amount (int): The amount to be transferred.
1207        from_account (str): The account from which the value will be transferred.
1208        to_account (str): The account to which the value will be transferred.
1209        desc (str, optional): A description for the transaction. Defaults to an empty string.
1210        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1211        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1212
1213        Returns:
1214        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1215
1216        Raises:
1217        ValueError: Transfer to the same account is forbidden.
1218        ValueError: The box transaction happened again in the same nanosecond time.
1219        ValueError: The log transaction happened again in the same nanosecond time.
1220        """
1221        if debug:
1222            print('transfer', f'debug={debug}')
1223        if from_account == to_account:
1224            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1225        if amount <= 0:
1226            return []
1227        if created is None:
1228            created = self.time()
1229        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
1230        times = []
1231        source_exchange = self.exchange(from_account, created)
1232        target_exchange = self.exchange(to_account, created)
1233
1234        if debug:
1235            print('ages', ages)
1236
1237        for age, value in ages:
1238            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
1239            # Perform the transfer
1240            if self.box_exists(to_account, age):
1241                if debug:
1242                    print('box_exists', age)
1243                capital = self._vault['account'][to_account]['box'][age]['capital']
1244                rest = self._vault['account'][to_account]['box'][age]['rest']
1245                if debug:
1246                    print(
1247                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1248                selected_age = age
1249                if rest + target_amount > capital:
1250                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1251                    selected_age = ZakatTracker.time()
1252                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1253                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1254                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1255                              created=None, ref=None, debug=debug)
1256                times.append((age, y))
1257                continue
1258            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1259            if debug:
1260                print(
1261                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1262            times.append(y)
1263        return times

Transfers a specified value from one account to another.

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

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

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

def check( self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
1265    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1266              cycle: float = None) -> tuple:
1267        """
1268        Check the eligibility for Zakat based on the given parameters.
1269
1270        Parameters:
1271        silver_gram_price (float): The price of a gram of silver.
1272        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1273                        it will be calculated based on the silver_gram_price.
1274        debug (bool): Flag to enable debug mode.
1275        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1276        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1277
1278        Returns:
1279        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1280        and a dictionary containing the Zakat plan.
1281        """
1282        if debug:
1283            print('check', f'debug={debug}')
1284        if now is None:
1285            now = self.time()
1286        if cycle is None:
1287            cycle = ZakatTracker.TimeCycle()
1288        if nisab is None:
1289            nisab = ZakatTracker.Nisab(silver_gram_price)
1290        plan = {}
1291        below_nisab = 0
1292        brief = [0, 0, 0]
1293        valid = False
1294        if debug:
1295            print('exchanges', self.exchanges())
1296        for x in self._vault['account']:
1297            if not self.zakatable(x):
1298                continue
1299            _box = self._vault['account'][x]['box']
1300            _log = self._vault['account'][x]['log']
1301            limit = len(_box) + 1
1302            ids = sorted(self._vault['account'][x]['box'].keys())
1303            for i in range(-1, -limit, -1):
1304                j = ids[i]
1305                rest = float(_box[j]['rest'])
1306                if rest <= 0:
1307                    continue
1308                exchange = self.exchange(x, created=self.time())
1309                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1310                brief[0] += rest
1311                index = limit + i - 1
1312                epoch = (now - j) / cycle
1313                if debug:
1314                    print(f"Epoch: {epoch}", _box[j])
1315                if _box[j]['last'] > 0:
1316                    epoch = (now - _box[j]['last']) / cycle
1317                if debug:
1318                    print(f"Epoch: {epoch}")
1319                epoch = floor(epoch)
1320                if debug:
1321                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1322                if epoch == 0:
1323                    continue
1324                if debug:
1325                    print("Epoch - PASSED")
1326                brief[1] += rest
1327                if rest >= nisab:
1328                    total = 0
1329                    for _ in range(epoch):
1330                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1331                    if total > 0:
1332                        if x not in plan:
1333                            plan[x] = {}
1334                        valid = True
1335                        brief[2] += total
1336                        plan[x][index] = {
1337                            'total': total,
1338                            'count': epoch,
1339                            'box_time': j,
1340                            'box_capital': _box[j]['capital'],
1341                            'box_rest': _box[j]['rest'],
1342                            'box_last': _box[j]['last'],
1343                            'box_total': _box[j]['total'],
1344                            'box_count': _box[j]['count'],
1345                            'box_log': _log[j]['desc'],
1346                            'exchange_rate': exchange['rate'],
1347                            'exchange_time': exchange['time'],
1348                            'exchange_desc': exchange['description'],
1349                        }
1350                else:
1351                    chunk = ZakatTracker.ZakatCut(float(rest))
1352                    if chunk > 0:
1353                        if x not in plan:
1354                            plan[x] = {}
1355                        if j not in plan[x].keys():
1356                            plan[x][index] = {}
1357                        below_nisab += rest
1358                        brief[2] += chunk
1359                        plan[x][index]['below_nisab'] = chunk
1360                        plan[x][index]['total'] = chunk
1361                        plan[x][index]['count'] = epoch
1362                        plan[x][index]['box_time'] = j
1363                        plan[x][index]['box_capital'] = _box[j]['capital']
1364                        plan[x][index]['box_rest'] = _box[j]['rest']
1365                        plan[x][index]['box_last'] = _box[j]['last']
1366                        plan[x][index]['box_total'] = _box[j]['total']
1367                        plan[x][index]['box_count'] = _box[j]['count']
1368                        plan[x][index]['box_log'] = _log[j]['desc']
1369                        plan[x][index]['exchange_rate'] = exchange['rate']
1370                        plan[x][index]['exchange_time'] = exchange['time']
1371                        plan[x][index]['exchange_desc'] = exchange['description']
1372        valid = valid or below_nisab >= nisab
1373        if debug:
1374            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1375        return valid, brief, plan

Check the eligibility for Zakat based on the given parameters.

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

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

def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1377    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1378        """
1379        Build payment parts for the Zakat distribution.
1380
1381        Parameters:
1382        demand (float): The total demand for payment in local currency.
1383        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1384
1385        Returns:
1386        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1387        {
1388            'account': {
1389                'account_id': {'balance': float, 'rate': float, 'part': float},
1390                ...
1391            },
1392            'exceed': bool,
1393            'demand': float,
1394            'total': float,
1395        }
1396        """
1397        total = 0
1398        parts = {
1399            'account': {},
1400            'exceed': False,
1401            'demand': demand,
1402        }
1403        for x, y in self.accounts().items():
1404            if positive_only and y <= 0:
1405                continue
1406            total += float(y)
1407            exchange = self.exchange(x)
1408            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1409        parts['total'] = total
1410        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:
1412    @staticmethod
1413    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1414        """
1415        Checks the validity of payment parts.
1416
1417        Parameters:
1418        parts (dict): A dictionary containing payment parts information.
1419        debug (bool): Flag to enable debug mode.
1420
1421        Returns:
1422        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1423
1424        Error Codes:
1425        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1426        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1427        3: 'part' value in parts['account'][x] is less than 0.
1428        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1429        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1430        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1431        """
1432        if debug:
1433            print('check_payment_parts', f'debug={debug}')
1434        for i in ['demand', 'account', 'total', 'exceed']:
1435            if i not in parts:
1436                return 1
1437        exceed = parts['exceed']
1438        for x in parts['account']:
1439            for j in ['balance', 'rate', 'part']:
1440                if j not in parts['account'][x]:
1441                    return 2
1442                if parts['account'][x]['part'] < 0:
1443                    return 3
1444                if not exceed and parts['account'][x]['balance'] <= 0:
1445                    return 4
1446        demand = parts['demand']
1447        z = 0
1448        for _, y in parts['account'].items():
1449            if not exceed and y['part'] > y['balance']:
1450                return 5
1451            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1452        z = round(z, 2)
1453        demand = round(demand, 2)
1454        if debug:
1455            print('check_payment_parts', f'z = {z}, demand = {demand}')
1456            print('check_payment_parts', type(z), type(demand))
1457            print('check_payment_parts', z != demand)
1458            print('check_payment_parts', str(z) != str(demand))
1459        if z != demand and str(z) != str(demand):
1460            return 6
1461        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:
1463    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1464        """
1465        Perform Zakat calculation based on the given report and optional parts.
1466
1467        Parameters:
1468        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1469        parts (dict): A dictionary containing the payment parts for the zakat.
1470        debug (bool): A flag indicating whether to print debug information.
1471
1472        Returns:
1473        bool: True if the zakat calculation is successful, False otherwise.
1474        """
1475        if debug:
1476            print('zakat', f'debug={debug}')
1477        valid, _, plan = report
1478        if not valid:
1479            return valid
1480        parts_exist = parts is not None
1481        if parts_exist:
1482            if self.check_payment_parts(parts, debug=debug) != 0:
1483                return False
1484        if debug:
1485            print('######### zakat #######')
1486            print('parts_exist', parts_exist)
1487        no_lock = self.nolock()
1488        self.lock()
1489        report_time = self.time()
1490        self._vault['report'][report_time] = report
1491        self._step(Action.REPORT, ref=report_time)
1492        created = self.time()
1493        for x in plan:
1494            target_exchange = self.exchange(x)
1495            if debug:
1496                print(plan[x])
1497                print('-------------')
1498                print(self._vault['account'][x]['box'])
1499            ids = sorted(self._vault['account'][x]['box'].keys())
1500            if debug:
1501                print('plan[x]', plan[x])
1502            for i in plan[x].keys():
1503                j = ids[i]
1504                if debug:
1505                    print('i', i, 'j', j)
1506                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1507                           key='last',
1508                           math_operation=MathOperation.EQUAL)
1509                self._vault['account'][x]['box'][j]['last'] = created
1510                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1511                self._vault['account'][x]['box'][j]['total'] += amount
1512                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1513                           math_operation=MathOperation.ADDITION)
1514                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1515                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1516                           math_operation=MathOperation.ADDITION)
1517                if not parts_exist:
1518                    try:
1519                        self._vault['account'][x]['box'][j]['rest'] -= amount
1520                    except TypeError:
1521                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1522                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1523                    #            math_operation=MathOperation.SUBTRACTION)
1524                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1525        if parts_exist:
1526            for account, part in parts['account'].items():
1527                if part['part'] == 0:
1528                    continue
1529                if debug:
1530                    print('zakat-part', account, part['rate'])
1531                target_exchange = self.exchange(account)
1532                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1533                self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug)
1534        if no_lock:
1535            self.free(self.lock())
1536        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:
1538    def export_json(self, path: str = "data.json") -> bool:
1539        """
1540        Exports the current state of the ZakatTracker object to a JSON file.
1541
1542        Parameters:
1543        path (str): The path where the JSON file will be saved. Default is "data.json".
1544
1545        Returns:
1546        bool: True if the export is successful, False otherwise.
1547
1548        Raises:
1549        No specific exceptions are raised by this method.
1550        """
1551        with open(path, "w") as file:
1552            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1553            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:
1555    def save(self, path: str = None) -> bool:
1556        """
1557        Saves the ZakatTracker's current state to a pickle file.
1558
1559        This method serializes the internal data (`_vault`) along with metadata
1560        (Python version, pickle protocol) for future compatibility.
1561
1562        Parameters:
1563        path (str, optional): File path for saving. Defaults to a predefined location.
1564
1565        Returns:
1566        bool: True if the save operation is successful, False otherwise.
1567        """
1568        if path is None:
1569            path = self.path()
1570        with open(path, "wb") as f:
1571            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1572            pickle_protocol = pickle.HIGHEST_PROTOCOL
1573            data = {
1574                'python_version': version,
1575                'pickle_protocol': pickle_protocol,
1576                'data': self._vault,
1577            }
1578            pickle.dump(data, f, protocol=pickle_protocol)
1579            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:
1581    def load(self, path: str = None) -> bool:
1582        """
1583        Load the current state of the ZakatTracker object from a pickle file.
1584
1585        Parameters:
1586        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1587
1588        Returns:
1589        bool: True if the load operation is successful, False otherwise.
1590        """
1591        if path is None:
1592            path = self.path()
1593        if os.path.exists(path):
1594            with open(path, "rb") as f:
1595                data = pickle.load(f)
1596                self._vault = data['data']
1597                return True
1598        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):
1600    def import_csv_cache_path(self):
1601        """
1602        Generates the cache file path for imported CSV data.
1603
1604        This function constructs the file path where cached data from CSV imports
1605        will be stored. The cache file is a pickle file (.pickle extension) appended
1606        to the base path of the object.
1607
1608        Returns:
1609        str: The full path to the import CSV cache file.
1610
1611        Example:
1612            >>> obj = ZakatTracker('/data/reports')
1613            >>> obj.import_csv_cache_path()
1614            '/data/reports.import_csv.pickle'
1615        """
1616        path = self.path()
1617        if path.endswith(".pickle"):
1618            path = path[:-7]
1619        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:
1621    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1622        """
1623        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1624
1625        Parameters:
1626        path (str): The path to the CSV file. Default is 'file.csv'.
1627        debug (bool): A flag indicating whether to print debug information.
1628
1629        Returns:
1630        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1631                and a dictionary of bad transactions.
1632
1633        Notes:
1634            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1635                                        are appropriate for the currency pairs involved in the conversions.
1636            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1637                to 1.0 or the previous rate for that account.
1638            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1639              transactions of the same account within the whole imported and existing dataset when doing `check` and
1640              `zakat` operations.
1641
1642        Example Usage:
1643            The CSV file should have the following format, rate is optional per transaction:
1644            account, desc, value, date, rate
1645            For example:
1646            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1647        """
1648        if debug:
1649            print('import_csv', f'debug={debug}')
1650        cache: list[int] = []
1651        try:
1652            with open(self.import_csv_cache_path(), "rb") as f:
1653                cache = pickle.load(f)
1654        except:
1655            pass
1656        date_formats = [
1657            "%Y-%m-%d %H:%M:%S",
1658            "%Y-%m-%dT%H:%M:%S",
1659            "%Y-%m-%dT%H%M%S",
1660            "%Y-%m-%d",
1661        ]
1662        created, found, bad = 0, 0, {}
1663        data: dict[int, list] = {}
1664        with open(path, newline='', encoding="utf-8") as f:
1665            i = 0
1666            for row in csv.reader(f, delimiter=','):
1667                i += 1
1668                hashed = hash(tuple(row))
1669                if hashed in cache:
1670                    found += 1
1671                    continue
1672                account = row[0]
1673                desc = row[1]
1674                value = float(row[2])
1675                rate = 1.0
1676                if row[4:5]:  # Empty list if index is out of range
1677                    rate = float(row[4])
1678                date: int = 0
1679                for time_format in date_formats:
1680                    try:
1681                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1682                        break
1683                    except:
1684                        pass
1685                # TODO: not allowed for negative dates
1686                if date == 0 or value == 0:
1687                    bad[i] = row
1688                    continue
1689                if date not in data:
1690                    data[date] = []
1691                # TODO: If duplicated time with different accounts with the same amount it is an indicator of a transfer
1692                data[date].append((date, value, desc, account, rate, hashed))
1693
1694        if debug:
1695            print('import_csv', len(data))
1696
1697        def process(row, index=0):
1698            nonlocal created
1699            (date, value, desc, account, rate, hashed) = row
1700            date += index
1701            if rate > 1:
1702                self.exchange(account, created=date, rate=rate)
1703            if value > 0:
1704                self.track(value, desc, account, True, date)
1705            elif value < 0:
1706                self.sub(-value, desc, account, date)
1707            created += 1
1708            cache.append(hashed)
1709
1710        for date, rows in sorted(data.items()):
1711            len_rows = len(rows)
1712            if len_rows == 1:
1713                process(rows[0])
1714                continue
1715            if debug:
1716                print('-- Duplicated time detected', date, 'len', len_rows)
1717                print(rows)
1718                print('---------------------------------')
1719            for index, row in enumerate(rows):
1720                process(row, index)
1721        with open(self.import_csv_cache_path(), "wb") as f:
1722            pickle.dump(cache, f)
1723        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:
1729    @staticmethod
1730    def human_readable_size(size: float, decimal_places: int = 2) -> str:
1731        """
1732        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
1733
1734        This function iterates through progressively larger units of information
1735        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
1736        range that can be expressed with a reasonable number before the unit.
1737
1738        Parameters:
1739        size (float): The size in bytes to convert.
1740        decimal_places (int, optional): The number of decimal places to display
1741            in the result. Defaults to 2.
1742
1743        Returns:
1744        str: A string representation of the size in a human-readable format,
1745            rounded to the specified number of decimal places. For example:
1746                - "1.50 KB" (1536 bytes)
1747                - "23.00 MB" (24117248 bytes)
1748                - "1.23 GB" (1325899906 bytes)
1749        """
1750        if type(size) not in (float, int):
1751            raise TypeError("size must be a float or integer")
1752        if type(decimal_places) != int:
1753            raise TypeError("decimal_places must be an integer")
1754        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
1755            if size < 1024.0:
1756                break
1757            size /= 1024.0
1758        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:
1760    @staticmethod
1761    def get_dict_size(obj: dict, seen: set = None) -> float:
1762        """
1763        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
1764
1765        This function traverses the dictionary structure, accounting for the size of keys, values,
1766        and any nested objects. It handles various data types commonly found in dictionaries
1767        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
1768        of circular references.
1769
1770        Parameters:
1771        obj (dict): The dictionary whose size is to be calculated.
1772        seen (set, optional): A set used internally to track visited objects
1773                             and avoid circular references. Defaults to None.
1774
1775        Returns:
1776            float: An approximate size of the dictionary and its contents in bytes.
1777
1778        Note:
1779        - This function is a method of the `ZakatTracker` class and is likely used to
1780          estimate the memory footprint of data structures relevant to Zakat calculations.
1781        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
1782          not account for all memory overhead depending on the Python implementation.
1783        - Circular references are handled to prevent infinite recursion.
1784        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
1785        - String sizes are estimated based on character length and encoding.
1786        """
1787        size = 0
1788        if seen is None:
1789            seen = set()
1790
1791        obj_id = id(obj)
1792        if obj_id in seen:
1793            return 0
1794
1795        seen.add(obj_id)
1796        size += sys.getsizeof(obj)
1797
1798        if isinstance(obj, dict):
1799            for k, v in obj.items():
1800                size += ZakatTracker.get_dict_size(k, seen)
1801                size += ZakatTracker.get_dict_size(v, seen)
1802        elif isinstance(obj, (list, tuple, set, frozenset)):
1803            for item in obj:
1804                size += ZakatTracker.get_dict_size(item, seen)
1805        elif isinstance(obj, (int, float, complex)):  # Handle numbers
1806            pass  # Basic numbers have a fixed size, so nothing to add here
1807        elif isinstance(obj, str):  # Handle strings
1808            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
1809        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:
1811    @staticmethod
1812    def duration_from_nanoseconds(ns: int,
1813                                  show_zeros_in_spoken_time: bool = False,
1814                                  spoken_time_separator=',',
1815                                  millennia: str = 'Millennia',
1816                                  century: str = 'Century',
1817                                  years: str = 'Years',
1818                                  days: str = 'Days',
1819                                  hours: str = 'Hours',
1820                                  minutes: str = 'Minutes',
1821                                  seconds: str = 'Seconds',
1822                                  milli_seconds: str = 'MilliSeconds',
1823                                  micro_seconds: str = 'MicroSeconds',
1824                                  nano_seconds: str = 'NanoSeconds',
1825                                  ) -> tuple:
1826        """
1827        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1828        Convert NanoSeconds to Human Readable Time Format.
1829        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1830        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1831        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1832        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1833
1834        INPUT : ms (AKA: MilliSeconds)
1835        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1836        OUTPUT Variables: time_lapsed, spoken_time
1837
1838        Example  Input: duration_from_nanoseconds(ns)
1839        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1840        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')
1841        duration_from_nanoseconds(1234567890123456789012)
1842        """
1843        us, ns = divmod(ns, 1000)
1844        ms, us = divmod(us, 1000)
1845        s, ms = divmod(ms, 1000)
1846        m, s = divmod(s, 60)
1847        h, m = divmod(m, 60)
1848        d, h = divmod(h, 24)
1849        y, d = divmod(d, 365)
1850        c, y = divmod(y, 100)
1851        n, c = divmod(c, 10)
1852        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}"
1853        spoken_time_part = []
1854        if n > 0 or show_zeros_in_spoken_time:
1855            spoken_time_part.append(f"{n: 3d} {millennia}")
1856        if c > 0 or show_zeros_in_spoken_time:
1857            spoken_time_part.append(f"{c: 4d} {century}")
1858        if y > 0 or show_zeros_in_spoken_time:
1859            spoken_time_part.append(f"{y: 3d} {years}")
1860        if d > 0 or show_zeros_in_spoken_time:
1861            spoken_time_part.append(f"{d: 4d} {days}")
1862        if h > 0 or show_zeros_in_spoken_time:
1863            spoken_time_part.append(f"{h: 2d} {hours}")
1864        if m > 0 or show_zeros_in_spoken_time:
1865            spoken_time_part.append(f"{m: 2d} {minutes}")
1866        if s > 0 or show_zeros_in_spoken_time:
1867            spoken_time_part.append(f"{s: 2d} {seconds}")
1868        if ms > 0 or show_zeros_in_spoken_time:
1869            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
1870        if us > 0 or show_zeros_in_spoken_time:
1871            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
1872        if ns > 0 or show_zeros_in_spoken_time:
1873            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
1874        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:
1876    @staticmethod
1877    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
1878        """
1879        Convert a specific day, month, and year into a timestamp.
1880
1881        Parameters:
1882        day (int): The day of the month.
1883        month (int): The month of the year. Default is 6 (June).
1884        year (int): The year. Default is 2024.
1885
1886        Returns:
1887        int: The timestamp representing the given day, month, and year.
1888
1889        Note:
1890        This method assumes the default month and year if not provided.
1891        """
1892        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:
1894    @staticmethod
1895    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1896        """
1897        Generate a random date between two given dates.
1898
1899        Parameters:
1900        start_date (datetime.datetime): The start date from which to generate a random date.
1901        end_date (datetime.datetime): The end date until which to generate a random date.
1902
1903        Returns:
1904        datetime.datetime: A random date between the start_date and end_date.
1905        """
1906        time_between_dates = end_date - start_date
1907        days_between_dates = time_between_dates.days
1908        random_number_of_days = random.randrange(days_between_dates)
1909        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:
1911    @staticmethod
1912    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
1913                                 debug: bool = False) -> int:
1914        """
1915        Generate a random CSV file with specified parameters.
1916
1917        Parameters:
1918        path (str): The path where the CSV file will be saved. Default is "data.csv".
1919        count (int): The number of rows to generate in the CSV file. Default is 1000.
1920        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
1921        debug (bool): A flag indicating whether to print debug information.
1922
1923        Returns:
1924        None. The function generates a CSV file at the specified path with the given count of rows.
1925        Each row contains a randomly generated account, description, value, and date.
1926        The value is randomly generated between 1000 and 100000,
1927        and the date is randomly generated between 1950-01-01 and 2023-12-31.
1928        If the row number is not divisible by 13, the value is multiplied by -1.
1929        """
1930        if debug:
1931            print('generate_random_csv_file', f'debug={debug}')
1932        i = 0
1933        with open(path, "w", newline="") as csvfile:
1934            writer = csv.writer(csvfile)
1935            for i in range(count):
1936                account = f"acc-{random.randint(1, 1000)}"
1937                desc = f"Some text {random.randint(1, 1000)}"
1938                value = random.randint(1000, 100000)
1939                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
1940                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1941                if not i % 13 == 0:
1942                    value *= -1
1943                row = [account, desc, value, date]
1944                if with_rate:
1945                    rate = random.randint(1, 100) * 0.12
1946                    if debug:
1947                        print('before-append', row)
1948                    row.append(rate)
1949                    if debug:
1950                        print('after-append', row)
1951                writer.writerow(row)
1952                i = i + 1
1953        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):
1955    @staticmethod
1956    def create_random_list(max_sum, min_value=0, max_value=10):
1957        """
1958        Creates a list of random integers whose sum does not exceed the specified maximum.
1959
1960        Args:
1961            max_sum: The maximum allowed sum of the list elements.
1962            min_value: The minimum possible value for an element (inclusive).
1963            max_value: The maximum possible value for an element (inclusive).
1964
1965        Returns:
1966            A list of random integers.
1967        """
1968        result = []
1969        current_sum = 0
1970
1971        while current_sum < max_sum:
1972            # Calculate the remaining space for the next element
1973            remaining_sum = max_sum - current_sum
1974            # Determine the maximum possible value for the next element
1975            next_max_value = min(remaining_sum, max_value)
1976            # Generate a random element within the allowed range
1977            next_element = random.randint(min_value, next_max_value)
1978            result.append(next_element)
1979            current_sum += next_element
1980
1981        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:
2188    def test(self, debug: bool = False) -> bool:
2189        if debug:
2190            print('test', f'debug={debug}')
2191        try:
2192
2193            assert self._history()
2194
2195            # Not allowed for duplicate transactions in the same account and time
2196
2197            created = ZakatTracker.time()
2198            self.track(100, 'test-1', 'same', True, created)
2199            failed = False
2200            try:
2201                self.track(50, 'test-1', 'same', True, created)
2202            except:
2203                failed = True
2204            assert failed is True
2205
2206            self.reset()
2207
2208            # Same account transfer
2209            for x in [1, 'a', True, 1.8, None]:
2210                failed = False
2211                try:
2212                    self.transfer(1, x, x, 'same-account', debug=debug)
2213                except:
2214                    failed = True
2215                assert failed is True
2216
2217            # Always preserve box age during transfer
2218
2219            series: list[tuple] = [
2220                (30, 4),
2221                (60, 3),
2222                (90, 2),
2223            ]
2224            case = {
2225                30: {
2226                    'series': series,
2227                    'rest': 150,
2228                },
2229                60: {
2230                    'series': series,
2231                    'rest': 120,
2232                },
2233                90: {
2234                    'series': series,
2235                    'rest': 90,
2236                },
2237                180: {
2238                    'series': series,
2239                    'rest': 0,
2240                },
2241                270: {
2242                    'series': series,
2243                    'rest': -90,
2244                },
2245                360: {
2246                    'series': series,
2247                    'rest': -180,
2248                },
2249            }
2250
2251            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2252
2253            for total in case:
2254                for x in case[total]['series']:
2255                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
2256
2257                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
2258
2259                if debug:
2260                    print('refs', refs)
2261
2262                ages_cache_balance = self.balance('ages')
2263                ages_fresh_balance = self.balance('ages', False)
2264                rest = case[total]['rest']
2265                if debug:
2266                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2267                assert ages_cache_balance == rest
2268                assert ages_fresh_balance == rest
2269
2270                future_cache_balance = self.balance('future')
2271                future_fresh_balance = self.balance('future', False)
2272                if debug:
2273                    print('target', future_cache_balance, future_fresh_balance, total)
2274                    print('refs', refs)
2275                assert future_cache_balance == total
2276                assert future_fresh_balance == total
2277
2278                for ref in self._vault['account']['ages']['box']:
2279                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2280                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2281                    future_capital = 0
2282                    if ref in self._vault['account']['future']['box']:
2283                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2284                    future_rest = 0
2285                    if ref in self._vault['account']['future']['box']:
2286                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2287                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2288                        if debug:
2289                            print('================================================================')
2290                            print('ages', ages_capital, ages_rest)
2291                            print('future', future_capital, future_rest)
2292                        if ages_rest == 0:
2293                            assert ages_capital == future_capital
2294                        elif ages_rest < 0:
2295                            assert -ages_capital == future_capital
2296                        elif ages_rest > 0:
2297                            assert ages_capital == ages_rest + future_capital
2298                self.reset()
2299                assert len(self._vault['history']) == 0
2300
2301            assert self._history()
2302            assert self._history(False) is False
2303            assert self._history() is False
2304            assert self._history(True)
2305            assert self._history()
2306
2307            self._test_core(True, debug)
2308            self._test_core(False, debug)
2309
2310            transaction = [
2311                (
2312                    20, 'wallet', 1, 800, 800, 800, 4, 5,
2313                    -85, -85, -85, 6, 7,
2314                ),
2315                (
2316                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
2317                    750, 750, 750, 1, 1,
2318                ),
2319                (
2320                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
2321                    600, 600, 600, 1, 1,
2322                ),
2323            ]
2324            for z in transaction:
2325                self.lock()
2326                x = z[1]
2327                y = z[2]
2328                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
2329                assert self.balance(x) == z[3]
2330                xx = self.accounts()[x]
2331                assert xx == z[3]
2332                assert self.balance(x, False) == z[4]
2333                assert xx == z[4]
2334
2335                s = 0
2336                log = self._vault['account'][x]['log']
2337                for i in log:
2338                    s += log[i]['value']
2339                if debug:
2340                    print('s', s, 'z[5]', z[5])
2341                assert s == z[5]
2342
2343                assert self.box_size(x) == z[6]
2344                assert self.log_size(x) == z[7]
2345
2346                yy = self.accounts()[y]
2347                assert self.balance(y) == z[8]
2348                assert yy == z[8]
2349                assert self.balance(y, False) == z[9]
2350                assert yy == z[9]
2351
2352                s = 0
2353                log = self._vault['account'][y]['log']
2354                for i in log:
2355                    s += log[i]['value']
2356                assert s == z[10]
2357
2358                assert self.box_size(y) == z[11]
2359                assert self.log_size(y) == z[12]
2360
2361            if debug:
2362                pp().pprint(self.check(2.17))
2363
2364            assert not self.nolock()
2365            history_count = len(self._vault['history'])
2366            if debug:
2367                print('history-count', history_count)
2368            assert history_count == 11
2369            assert not self.free(ZakatTracker.time())
2370            assert self.free(self.lock())
2371            assert self.nolock()
2372            assert len(self._vault['history']) == 11
2373
2374            # storage
2375
2376            _path = self.path('test.pickle')
2377            if os.path.exists(_path):
2378                os.remove(_path)
2379            self.save()
2380            assert os.path.getsize(_path) > 0
2381            self.reset()
2382            assert self.recall(False, debug) is False
2383            self.load()
2384            assert self._vault['account'] is not None
2385
2386            # recall
2387
2388            assert self.nolock()
2389            assert len(self._vault['history']) == 11
2390            assert self.recall(False, debug) is True
2391            assert len(self._vault['history']) == 10
2392            assert self.recall(False, debug) is True
2393            assert len(self._vault['history']) == 9
2394
2395            # exchange
2396
2397            self.exchange("cash", 25, 3.75, "2024-06-25")
2398            self.exchange("cash", 22, 3.73, "2024-06-22")
2399            self.exchange("cash", 15, 3.69, "2024-06-15")
2400            self.exchange("cash", 10, 3.66)
2401
2402            for i in range(1, 30):
2403                exchange = self.exchange("cash", i)
2404                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2405                if debug:
2406                    print(i, rate, description, created)
2407                assert created
2408                if i < 10:
2409                    assert rate == 1
2410                    assert description is None
2411                elif i == 10:
2412                    assert rate == 3.66
2413                    assert description is None
2414                elif i < 15:
2415                    assert rate == 3.66
2416                    assert description is None
2417                elif i == 15:
2418                    assert rate == 3.69
2419                    assert description is not None
2420                elif i < 22:
2421                    assert rate == 3.69
2422                    assert description is not None
2423                elif i == 22:
2424                    assert rate == 3.73
2425                    assert description is not None
2426                elif i >= 25:
2427                    assert rate == 3.75
2428                    assert description is not None
2429                exchange = self.exchange("bank", i)
2430                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2431                if debug:
2432                    print(i, rate, description, created)
2433                assert created
2434                assert rate == 1
2435                assert description is None
2436
2437            assert len(self._vault['exchange']) > 0
2438            assert len(self.exchanges()) > 0
2439            self._vault['exchange'].clear()
2440            assert len(self._vault['exchange']) == 0
2441            assert len(self.exchanges()) == 0
2442
2443            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2444            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2445            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2446            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2447            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2448
2449            for i in [x * 0.12 for x in range(-15, 21)]:
2450                if i <= 0:
2451                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2452                else:
2453                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2454
2455            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2456            for i in range(1, 31):
2457                timestamp_ns = ZakatTracker.day_to_time(i)
2458                exchange = self.exchange("cash", timestamp_ns)
2459                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2460                if debug:
2461                    print(i, rate, description, created)
2462                assert created
2463                if i < 10:
2464                    assert rate == 1
2465                    assert description is None
2466                elif i == 10:
2467                    assert rate == 3.66
2468                    assert description is None
2469                elif i < 15:
2470                    assert rate == 3.66
2471                    assert description is None
2472                elif i == 15:
2473                    assert rate == 3.69
2474                    assert description is not None
2475                elif i < 22:
2476                    assert rate == 3.69
2477                    assert description is not None
2478                elif i == 22:
2479                    assert rate == 3.73
2480                    assert description is not None
2481                elif i >= 25:
2482                    assert rate == 3.75
2483                    assert description is not None
2484                exchange = self.exchange("bank", i)
2485                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2486                if debug:
2487                    print(i, rate, description, created)
2488                assert created
2489                assert rate == 1
2490                assert description is None
2491
2492            # csv
2493
2494            csv_count = 1000
2495
2496            for with_rate, path in {
2497                False: 'test-import_csv-no-exchange',
2498                True: 'test-import_csv-with-exchange',
2499            }.items():
2500
2501                if debug:
2502                    print('test_import_csv', with_rate, path)
2503
2504                # csv
2505
2506                csv_path = path + '.csv'
2507                if os.path.exists(csv_path):
2508                    os.remove(csv_path)
2509                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2510                if debug:
2511                    print('generate_random_csv_file', c)
2512                assert c == csv_count
2513                assert os.path.getsize(csv_path) > 0
2514                cache_path = self.import_csv_cache_path()
2515                if os.path.exists(cache_path):
2516                    os.remove(cache_path)
2517                self.reset()
2518                (created, found, bad) = self.import_csv(csv_path, debug)
2519                bad_count = len(bad)
2520                if debug:
2521                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2522                tmp_size = os.path.getsize(cache_path)
2523                assert tmp_size > 0
2524                assert created + found + bad_count == csv_count
2525                assert created == csv_count
2526                assert bad_count == 0
2527                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2528                bad_2_count = len(bad_2)
2529                if debug:
2530                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2531                    print(bad)
2532                assert tmp_size == os.path.getsize(cache_path)
2533                assert created_2 + found_2 + bad_2_count == csv_count
2534                assert created == found_2
2535                assert bad_count == bad_2_count
2536                assert found_2 == csv_count
2537                assert bad_2_count == 0
2538                assert created_2 == 0
2539
2540                # payment parts
2541
2542                positive_parts = self.build_payment_parts(100, positive_only=True)
2543                assert self.check_payment_parts(positive_parts) != 0
2544                assert self.check_payment_parts(positive_parts) != 0
2545                all_parts = self.build_payment_parts(300, positive_only=False)
2546                assert self.check_payment_parts(all_parts) != 0
2547                assert self.check_payment_parts(all_parts) != 0
2548                if debug:
2549                    pp().pprint(positive_parts)
2550                    pp().pprint(all_parts)
2551                # dynamic discount
2552                suite = []
2553                count = 3
2554                for exceed in [False, True]:
2555                    case = []
2556                    for parts in [positive_parts, all_parts]:
2557                        part = parts.copy()
2558                        demand = part['demand']
2559                        if debug:
2560                            print(demand, part['total'])
2561                        i = 0
2562                        z = demand / count
2563                        cp = {
2564                            'account': {},
2565                            'demand': demand,
2566                            'exceed': exceed,
2567                            'total': part['total'],
2568                        }
2569                        j = ''
2570                        for x, y in part['account'].items():
2571                            x_exchange = self.exchange(x)
2572                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2573                            if exceed and zz <= demand:
2574                                i += 1
2575                                y['part'] = zz
2576                                if debug:
2577                                    print(exceed, y)
2578                                cp['account'][x] = y
2579                                case.append(y)
2580                            elif not exceed and y['balance'] >= zz:
2581                                i += 1
2582                                y['part'] = zz
2583                                if debug:
2584                                    print(exceed, y)
2585                                cp['account'][x] = y
2586                                case.append(y)
2587                            j = x
2588                            if i >= count:
2589                                break
2590                        if len(cp['account'][j]) > 0:
2591                            suite.append(cp)
2592                if debug:
2593                    print('suite', len(suite))
2594                # vault = self._vault.copy()
2595                for case in suite:
2596                    # self._vault = vault.copy()
2597                    if debug:
2598                        print('case', case)
2599                    result = self.check_payment_parts(case)
2600                    if debug:
2601                        print('check_payment_parts', result, f'exceed: {exceed}')
2602                    assert result == 0
2603
2604                    report = self.check(2.17, None, debug)
2605                    (valid, brief, plan) = report
2606                    if debug:
2607                        print('valid', valid)
2608                    zakat_result = self.zakat(report, parts=case, debug=debug)
2609                    if debug:
2610                        print('zakat-result', zakat_result)
2611                    assert valid == zakat_result
2612
2613            assert self.save(path + '.pickle')
2614            assert self.export_json(path + '.json')
2615
2616            assert self.export_json("1000-transactions-test.json")
2617            assert self.save("1000-transactions-test.pickle")
2618
2619            self.reset()
2620
2621            # test transfer between accounts with different exchange rate
2622
2623            a_SAR = "Bank (SAR)"
2624            b_USD = "Bank (USD)"
2625            c_SAR = "Safe (SAR)"
2626            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2627            for case in [
2628                (0, a_SAR, "SAR Gift", 1000, 1000),
2629                (1, a_SAR, 1),
2630                (0, b_USD, "USD Gift", 500, 500),
2631                (1, b_USD, 1),
2632                (2, b_USD, 3.75),
2633                (1, b_USD, 3.75),
2634                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2635                (0, c_SAR, "Salary", 750, 750),
2636                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2637                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2638            ]:
2639                match (case[0]):
2640                    case 0:  # track
2641                        _, account, desc, x, balance = case
2642                        self.track(value=x, desc=desc, account=account, debug=debug)
2643
2644                        cached_value = self.balance(account, cached=True)
2645                        fresh_value = self.balance(account, cached=False)
2646                        if debug:
2647                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2648                        assert cached_value == balance
2649                        assert fresh_value == balance
2650                    case 1:  # check-exchange
2651                        _, account, expected_rate = case
2652                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2653                        if debug:
2654                            print('t-exchange', t_exchange)
2655                        assert t_exchange['rate'] == expected_rate
2656                    case 2:  # do-exchange
2657                        _, account, rate = case
2658                        self.exchange(account, rate=rate, debug=debug)
2659                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2660                        if debug:
2661                            print('b-exchange', b_exchange)
2662                        assert b_exchange['rate'] == rate
2663                    case 3:  # transfer
2664                        _, x, a, b, desc, a_balance, b_balance = case
2665                        self.transfer(x, a, b, desc, debug=debug)
2666
2667                        cached_value = self.balance(a, cached=True)
2668                        fresh_value = self.balance(a, cached=False)
2669                        if debug:
2670                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2671                        assert cached_value == a_balance
2672                        assert fresh_value == a_balance
2673
2674                        cached_value = self.balance(b, cached=True)
2675                        fresh_value = self.balance(b, cached=False)
2676                        if debug:
2677                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2678                        assert cached_value == b_balance
2679                        assert fresh_value == b_balance
2680
2681            # Transfer all in many chunks randomly from B to A
2682            a_SAR_balance = 1371.25
2683            b_USD_balance = 501
2684            b_USD_exchange = self.exchange(b_USD)
2685            amounts = ZakatTracker.create_random_list(b_USD_balance)
2686            if debug:
2687                print('amounts', amounts)
2688            i = 0
2689            for x in amounts:
2690                if debug:
2691                    print(f'{i} - transfer-with-exchange({x})')
2692                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2693
2694                b_USD_balance -= x
2695                cached_value = self.balance(b_USD, cached=True)
2696                fresh_value = self.balance(b_USD, cached=False)
2697                if debug:
2698                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2699                          b_USD_balance)
2700                assert cached_value == b_USD_balance
2701                assert fresh_value == b_USD_balance
2702
2703                a_SAR_balance += x * b_USD_exchange['rate']
2704                cached_value = self.balance(a_SAR, cached=True)
2705                fresh_value = self.balance(a_SAR, cached=False)
2706                if debug:
2707                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2708                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2709                assert cached_value == a_SAR_balance
2710                assert fresh_value == a_SAR_balance
2711                i += 1
2712
2713            # Transfer all in many chunks randomly from C to A
2714            c_SAR_balance = 375
2715            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2716            if debug:
2717                print('amounts', amounts)
2718            i = 0
2719            for x in amounts:
2720                if debug:
2721                    print(f'{i} - transfer-with-exchange({x})')
2722                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2723
2724                c_SAR_balance -= x
2725                cached_value = self.balance(c_SAR, cached=True)
2726                fresh_value = self.balance(c_SAR, cached=False)
2727                if debug:
2728                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2729                          c_SAR_balance)
2730                assert cached_value == c_SAR_balance
2731                assert fresh_value == c_SAR_balance
2732
2733                a_SAR_balance += x
2734                cached_value = self.balance(a_SAR, cached=True)
2735                fresh_value = self.balance(a_SAR, cached=False)
2736                if debug:
2737                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2738                          a_SAR_balance)
2739                assert cached_value == a_SAR_balance
2740                assert fresh_value == a_SAR_balance
2741                i += 1
2742
2743            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2744            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2745
2746            # check & zakat with exchange rates for many cycles
2747
2748            for rate, values in {
2749                1: {
2750                    'in': [1000, 2000, 10000],
2751                    'exchanged': [1000, 2000, 10000],
2752                    'out': [25, 50, 731.40625],
2753                },
2754                3.75: {
2755                    'in': [200, 1000, 5000],
2756                    'exchanged': [750, 3750, 18750],
2757                    'out': [18.75, 93.75, 1371.38671875],
2758                },
2759            }.items():
2760                a, b, c = values['in']
2761                m, n, o = values['exchanged']
2762                x, y, z = values['out']
2763                if debug:
2764                    print('rate', rate, 'values', values)
2765                for case in [
2766                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2767                        {'safe': {0: {'below_nisab': x}}},
2768                    ], False, m),
2769                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2770                        {'safe': {0: {'count': 1, 'total': y}}},
2771                    ], True, n),
2772                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2773                        {'cave': {0: {'count': 3, 'total': z}}},
2774                    ], True, o),
2775                ]:
2776                    if debug:
2777                        print(f"############# check(rate: {rate}) #############")
2778                    self.reset()
2779                    self.exchange(account=case[1], created=case[2], rate=rate)
2780                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2781
2782                    # assert self.nolock()
2783                    # history_size = len(self._vault['history'])
2784                    # print('history_size', history_size)
2785                    # assert history_size == 2
2786                    assert self.lock()
2787                    assert not self.nolock()
2788                    report = self.check(2.17, None, debug)
2789                    (valid, brief, plan) = report
2790                    assert valid == case[4]
2791                    if debug:
2792                        print('brief', brief)
2793                    assert case[5] == brief[0]
2794                    assert case[5] == brief[1]
2795
2796                    if debug:
2797                        pp().pprint(plan)
2798
2799                    for x in plan:
2800                        assert case[1] == x
2801                        if 'total' in case[3][0][x][0].keys():
2802                            assert case[3][0][x][0]['total'] == brief[2]
2803                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2804                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2805                        else:
2806                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2807                    if debug:
2808                        pp().pprint(report)
2809                    result = self.zakat(report, debug=debug)
2810                    if debug:
2811                        print('zakat-result', result, case[4])
2812                    assert result == case[4]
2813                    report = self.check(2.17, None, debug)
2814                    (valid, brief, plan) = report
2815                    assert valid is False
2816
2817            history_size = len(self._vault['history'])
2818            if debug:
2819                print('history_size', history_size)
2820            assert history_size == 3
2821            assert not self.nolock()
2822            assert self.recall(False, debug) is False
2823            self.free(self.lock())
2824            assert self.nolock()
2825
2826            for i in range(3, 0, -1):
2827                history_size = len(self._vault['history'])
2828                if debug:
2829                    print('history_size', history_size)
2830                assert history_size == i
2831                assert self.recall(False, debug) is True
2832
2833            assert self.nolock()
2834            assert self.recall(False, debug) is False
2835
2836            history_size = len(self._vault['history'])
2837            if debug:
2838                print('history_size', history_size)
2839            assert history_size == 0
2840
2841            account_size = len(self._vault['account'])
2842            if debug:
2843                print('account_size', account_size)
2844            assert account_size == 0
2845
2846            report_size = len(self._vault['report'])
2847            if debug:
2848                print('report_size', report_size)
2849            assert report_size == 0
2850
2851            assert self.nolock()
2852            return True
2853        except:
2854            # pp().pprint(self._vault)
2855            assert self.export_json("test-snapshot.json")
2856            assert self.save("test-snapshot.pickle")
2857            raise
def test(debug: bool = False):
2860def test(debug: bool = False):
2861    ledger = ZakatTracker()
2862    start = ZakatTracker.time()
2863    assert ledger.test(debug=debug)
2864    if debug:
2865        print("#########################")
2866        print("######## TEST DONE ########")
2867        print("#########################")
2868        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
2869        print("#########################")
def main():
2872def main():
2873    test(debug=True)