zakat
xxx

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

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

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

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

A class for tracking and calculating Zakat.

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

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

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

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

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

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

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

Initialize ZakatTracker with database path and history mode.

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

Returns: None

@staticmethod
def Version() -> str:
176    @staticmethod
177    def Version() -> str:
178        """
179        Returns the current version of the software.
180
181        This function returns a string representing the current version of the software,
182        including major, minor, and patch version numbers in the format "X.Y.Z".
183
184        Returns:
185        str: The current version of the software.
186        """
187        return '0.2.80'

Returns the current version of the software.

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

Returns: str: The current version of the software.

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

Calculates the Zakat amount due on an asset.

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

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

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

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

Calculates the approximate duration of a lunar year in nanoseconds.

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

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

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

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

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

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

Parameters:

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

Returns:

  • float: The total value of Nisab based on the given price per gram.
def path(self, path: str = None) -> str:
261    def path(self, path: str = None) -> str:
262        """
263        Set or get the path to the database file.
264
265        If no path is provided, the current path is returned.
266        If a path is provided, it is set as the new path.
267        The function also creates the necessary directories if the provided path is a file.
268
269        Parameters:
270        path (str): The new path to the database file. If not provided, the current path is returned.
271
272        Returns:
273        str: The current or new path to the database file.
274        """
275        if path is None:
276            return self._vault_path
277        self._vault_path = Path(path).resolve()
278        base_path = Path(path).resolve()
279        if base_path.is_file() or base_path.suffix:
280            base_path = base_path.parent
281        base_path.mkdir(parents=True, exist_ok=True)
282        self._base_path = base_path
283        return str(self._vault_path)

Set or get the path to the database file.

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

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

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

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

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

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

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

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

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

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

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

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

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

Examples:

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

Unscales an integer by a power of 10.

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

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

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

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

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

@staticmethod
def time( now: <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'> = None) -> int:
394    @staticmethod
395    def time(now: datetime = None) -> int:
396        """
397        Generates a timestamp based on the provided datetime object or the current datetime.
398
399        Parameters:
400        now (datetime, optional): The datetime object to generate the timestamp from.
401        If not provided, the current datetime is used.
402
403        Returns:
404        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
405            before 1970 will return in negative until 1000AD.
406        """
407        if now is None:
408            now = datetime.datetime.now()
409        ordinal_day = now.toordinal()
410        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
411        return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)

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

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

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

@staticmethod
def time_to_datetime( ordinal_ns: int) -> <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'>:
413    @staticmethod
414    def time_to_datetime(ordinal_ns: int) -> datetime:
415        """
416        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
417
418        Parameters:
419        ordinal_ns (int): The ordinal number of days since 1000-01-01.
420
421        Returns:
422        datetime: The corresponding datetime object.
423        """
424        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
425        ns_in_day = ordinal_ns % 86_400_000_000_000
426        d = datetime.datetime.fromordinal(ordinal_day)
427        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
428        return datetime.datetime.combine(d, datetime.time()) + t

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

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

Returns: datetime: The corresponding datetime object.

def clean_history(self, lock: int | None = None) -> int:
430    def clean_history(self, lock: int | None = None) -> int:
431        """
432        Cleans up the history of actions performed on the ZakatTracker instance.
433
434        Parameters:
435        lock (int, optional): The lock ID is used to clean up the empty history.
436            If not provided, it cleans up the empty history records for all locks.
437
438        Returns:
439        int: The number of locks cleaned up.
440        """
441        count = 0
442        if lock in self._vault['history']:
443            if len(self._vault['history'][lock]) <= 0:
444                count += 1
445                del self._vault['history'][lock]
446            return count
447        self.free(self.lock())
448        for lock in self._vault['history']:
449            if len(self._vault['history'][lock]) <= 0:
450                count += 1
451                del self._vault['history'][lock]
452        return count

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

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

Returns: int: The number of locks cleaned up.

def nolock(self) -> bool:
490    def nolock(self) -> bool:
491        """
492        Check if the vault lock is currently not set.
493
494        Returns:
495        bool: True if the vault lock is not set, False otherwise.
496        """
497        return self._vault['lock'] is None

Check if the vault lock is currently not set.

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

def lock(self) -> int:
499    def lock(self) -> int:
500        """
501        Acquires a lock on the ZakatTracker instance.
502
503        Returns:
504        int: The lock ID. This ID can be used to release the lock later.
505        """
506        return self._step()

Acquires a lock on the ZakatTracker instance.

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

def vault(self) -> dict:
508    def vault(self) -> dict:
509        """
510        Returns a copy of the internal vault dictionary.
511
512        This method is used to retrieve the current state of the ZakatTracker object.
513        It provides a snapshot of the internal data structure, allowing for further
514        processing or analysis.
515
516        Returns:
517        dict: A copy of the internal vault dictionary.
518        """
519        return self._vault.copy()

Returns a copy of the internal vault dictionary.

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

Returns: dict: A copy of the internal vault dictionary.

def stats(self) -> dict[str, tuple]:
521    def stats(self) -> dict[str, tuple]:
522        """
523        Calculates and returns statistics about the object's data storage.
524
525        This method determines the size of the database file on disk and the
526        size of the data currently held in RAM (likely within a dictionary).
527        Both sizes are reported in bytes and in a human-readable format
528        (e.g., KB, MB).
529
530        Returns:
531        dict[str, tuple]: A dictionary containing the following statistics:
532
533            * 'database': A tuple with two elements:
534                - The database file size in bytes (int).
535                - The database file size in human-readable format (str).
536            * 'ram': A tuple with two elements:
537                - The RAM usage (dictionary size) in bytes (int).
538                - The RAM usage in human-readable format (str).
539
540        Example:
541        >>> stats = my_object.stats()
542        >>> print(stats['database'])
543        (256000, '250.0 KB')
544        >>> print(stats['ram'])
545        (12345, '12.1 KB')
546        """
547        ram_size = self.get_dict_size(self.vault())
548        file_size = os.path.getsize(self.path())
549        return {
550            'database': (file_size, self.human_readable_size(file_size)),
551            'ram': (ram_size, self.human_readable_size(ram_size)),
552        }

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

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

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

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

Example:

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

Retrieves information about files associated with this class.

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

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

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

Example:

file_info = MyClass.files()
for info in file_info:
    print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
def steps(self) -> dict:
596    def steps(self) -> dict:
597        """
598        Returns a copy of the history of steps taken in the ZakatTracker.
599
600        The history is a dictionary where each key is a unique identifier for a step,
601        and the corresponding value is a dictionary containing information about the step.
602
603        Returns:
604        dict: A copy of the history of steps taken in the ZakatTracker.
605        """
606        return self._vault['history'].copy()

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

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

Returns: dict: A copy of the history of steps taken in the ZakatTracker.

def free(self, lock: int, auto_save: bool = True) -> bool:
608    def free(self, lock: int, auto_save: bool = True) -> bool:
609        """
610        Releases the lock on the database.
611
612        Parameters:
613        lock (int): The lock ID to be released.
614        auto_save (bool): Whether to automatically save the database after releasing the lock.
615
616        Returns:
617        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
618        """
619        if lock == self._vault['lock']:
620            self._vault['lock'] = None
621            self.clean_history(lock)
622            if auto_save:
623                return self.save(self.path())
624            return True
625        return False

Releases the lock on the database.

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

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

def account_exists(self, account) -> bool:
627    def account_exists(self, account) -> bool:
628        """
629        Check if the given account exists in the vault.
630
631        Parameters:
632        account (str): The account number to check.
633
634        Returns:
635        bool: True if the account exists, False otherwise.
636        """
637        return account in self._vault['account']

Check if the given account exists in the vault.

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

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

def box_size(self, account) -> int:
639    def box_size(self, account) -> int:
640        """
641        Calculate the size of the box for a specific account.
642
643        Parameters:
644        account (str): The account number for which the box size needs to be calculated.
645
646        Returns:
647        int: The size of the box for the given account. If the account does not exist, -1 is returned.
648        """
649        if self.account_exists(account):
650            return len(self._vault['account'][account]['box'])
651        return -1

Calculate the size of the box for a specific account.

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

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

def log_size(self, account) -> int:
653    def log_size(self, account) -> int:
654        """
655        Get the size of the log for a specific account.
656
657        Parameters:
658        account (str): The account number for which the log size needs to be calculated.
659
660        Returns:
661        int: The size of the log for the given account. If the account does not exist, -1 is returned.
662        """
663        if self.account_exists(account):
664            return len(self._vault['account'][account]['log'])
665        return -1

Get the size of the log for a specific account.

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

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

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

Calculates the hash of a file using the specified algorithm.

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

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

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

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

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

Returns: str: The path to the cache file.

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

This function creates a snapshot of the current database state.

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

Parameters: None

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

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

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

Parameters:

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

Returns:

  • dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, and the values are tuples containing the snapshot's hash, path, and existence status.
def recall(self, dry=True, debug=False) -> bool:
764    def recall(self, dry=True, debug=False) -> bool:
765        """
766        Revert the last operation.
767
768        Parameters:
769        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
770        debug (bool): If True, the function will print debug information. Default is False.
771
772        Returns:
773        bool: True if the operation was successful, False otherwise.
774        """
775        if not self.nolock() or len(self._vault['history']) == 0:
776            return False
777        if len(self._vault['history']) <= 0:
778            return False
779        ref = sorted(self._vault['history'].keys())[-1]
780        if debug:
781            print('recall', ref)
782        memory = self._vault['history'][ref]
783        if debug:
784            print(type(memory), 'memory', memory)
785        limit = len(memory) + 1
786        sub_positive_log_negative = 0
787        for i in range(-1, -limit, -1):
788            x = memory[i]
789            if debug:
790                print(type(x), x)
791            match x['action']:
792                case Action.CREATE:
793                    if x['account'] is not None:
794                        if self.account_exists(x['account']):
795                            if debug:
796                                print('account', self._vault['account'][x['account']])
797                            assert len(self._vault['account'][x['account']]['box']) == 0
798                            assert self._vault['account'][x['account']]['balance'] == 0
799                            assert self._vault['account'][x['account']]['count'] == 0
800                            if dry:
801                                continue
802                            del self._vault['account'][x['account']]
803
804                case Action.TRACK:
805                    if x['account'] is not None:
806                        if self.account_exists(x['account']):
807                            if dry:
808                                continue
809                            self._vault['account'][x['account']]['balance'] -= x['value']
810                            self._vault['account'][x['account']]['count'] -= 1
811                            del self._vault['account'][x['account']]['box'][x['ref']]
812
813                case Action.LOG:
814                    if x['account'] is not None:
815                        if self.account_exists(x['account']):
816                            if x['ref'] in self._vault['account'][x['account']]['log']:
817                                if dry:
818                                    continue
819                                if sub_positive_log_negative == -x['value']:
820                                    self._vault['account'][x['account']]['count'] -= 1
821                                    sub_positive_log_negative = 0
822                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
823                                if not box_ref is None:
824                                    assert self.box_exists(x['account'], box_ref)
825                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
826                                    assert box_value < 0
827
828                                    try:
829                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
830                                    except TypeError:
831                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += Decimal(
832                                            -box_value)
833
834                                    try:
835                                        self._vault['account'][x['account']]['balance'] += -box_value
836                                    except TypeError:
837                                        self._vault['account'][x['account']]['balance'] += Decimal(-box_value)
838
839                                    self._vault['account'][x['account']]['count'] -= 1
840                                del self._vault['account'][x['account']]['log'][x['ref']]
841
842                case Action.SUB:
843                    if x['account'] is not None:
844                        if self.account_exists(x['account']):
845                            if x['ref'] in self._vault['account'][x['account']]['box']:
846                                if dry:
847                                    continue
848                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
849                                self._vault['account'][x['account']]['balance'] += x['value']
850                                sub_positive_log_negative = x['value']
851
852                case Action.ADD_FILE:
853                    if x['account'] is not None:
854                        if self.account_exists(x['account']):
855                            if x['ref'] in self._vault['account'][x['account']]['log']:
856                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
857                                    if dry:
858                                        continue
859                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
860
861                case Action.REMOVE_FILE:
862                    if x['account'] is not None:
863                        if self.account_exists(x['account']):
864                            if x['ref'] in self._vault['account'][x['account']]['log']:
865                                if dry:
866                                    continue
867                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
868
869                case Action.BOX_TRANSFER:
870                    if x['account'] is not None:
871                        if self.account_exists(x['account']):
872                            if x['ref'] in self._vault['account'][x['account']]['box']:
873                                if dry:
874                                    continue
875                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
876
877                case Action.EXCHANGE:
878                    if x['account'] is not None:
879                        if x['account'] in self._vault['exchange']:
880                            if x['ref'] in self._vault['exchange'][x['account']]:
881                                if dry:
882                                    continue
883                                del self._vault['exchange'][x['account']][x['ref']]
884
885                case Action.REPORT:
886                    if x['ref'] in self._vault['report']:
887                        if dry:
888                            continue
889                        del self._vault['report'][x['ref']]
890
891                case Action.ZAKAT:
892                    if x['account'] is not None:
893                        if self.account_exists(x['account']):
894                            if x['ref'] in self._vault['account'][x['account']]['box']:
895                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
896                                    if dry:
897                                        continue
898                                    match x['math']:
899                                        case MathOperation.ADDITION:
900                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
901                                                'value']
902                                        case MathOperation.EQUAL:
903                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
904                                        case MathOperation.SUBTRACTION:
905                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
906                                                'value']
907
908        if not dry:
909            del self._vault['history'][ref]
910        return True

Revert the last operation.

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

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

def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
912    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
913        """
914        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
915
916        Parameters:
917        account (str): The account number for which to check the existence of the reference.
918        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
919        ref (int): The reference (transaction) number to check for existence.
920
921        Returns:
922        bool: True if the reference exists for the given account and reference type, False otherwise.
923        """
924        if account in self._vault['account']:
925            return ref in self._vault['account'][account][ref_type]
926        return False

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

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

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

def box_exists(self, account: str, ref: int) -> bool:
928    def box_exists(self, account: str, ref: int) -> bool:
929        """
930        Check if a specific box (transaction) exists in the vault for a given account and reference.
931
932        Parameters:
933        - account (str): The account number for which to check the existence of the box.
934        - ref (int): The reference (transaction) number to check for existence.
935
936        Returns:
937        - bool: True if the box exists for the given account and reference, False otherwise.
938        """
939        return self.ref_exists(account, 'box', ref)

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

Parameters:

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

Returns:

  • bool: True if the box exists for the given account and reference, False otherwise.
def track( self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
 941    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
 942              debug: bool = False) -> int:
 943        """
 944        This function tracks a transaction for a specific account.
 945
 946        Parameters:
 947        value (float): The value of the transaction. Default is 0.
 948        desc (str): The description of the transaction. Default is an empty string.
 949        account (str): The account for which the transaction is being tracked. Default is '1'.
 950        logging (bool): Whether to log the transaction. Default is True.
 951        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
 952        debug (bool): Whether to print debug information. Default is False.
 953
 954        Returns:
 955        int: The timestamp of the transaction.
 956
 957        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.
 958
 959        Raises:
 960        ValueError: The log transaction happened again in the same nanosecond time.
 961        ValueError: The box transaction happened again in the same nanosecond time.
 962        """
 963        if debug:
 964            print('track', f'debug={debug}')
 965        if created is None:
 966            created = self.time()
 967        no_lock = self.nolock()
 968        self.lock()
 969        if not self.account_exists(account):
 970            if debug:
 971                print(f"account {account} created")
 972            self._vault['account'][account] = {
 973                'balance': 0,
 974                'box': {},
 975                'count': 0,
 976                'log': {},
 977                'hide': False,
 978                'zakatable': True,
 979            }
 980            self._step(Action.CREATE, account)
 981        if value == 0:
 982            if no_lock:
 983                self.free(self.lock())
 984            return 0
 985        if logging:
 986            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
 987        if debug:
 988            print('create-box', created)
 989        if self.box_exists(account, created):
 990            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
 991        if debug:
 992            print('created-box', created)
 993        self._vault['account'][account]['box'][created] = {
 994            'capital': value,
 995            'count': 0,
 996            'last': 0,
 997            'rest': value,
 998            'total': 0,
 999        }
1000        self._step(Action.TRACK, account, ref=created, value=value)
1001        if no_lock:
1002            self.free(self.lock())
1003        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:
1005    def log_exists(self, account: str, ref: int) -> bool:
1006        """
1007        Checks if a specific transaction log entry exists for a given account.
1008
1009        Parameters:
1010        account (str): The account number associated with the transaction log.
1011        ref (int): The reference to the transaction log entry.
1012
1013        Returns:
1014        bool: True if the transaction log entry exists, False otherwise.
1015        """
1016        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:
1062    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
1063                 debug: bool = False) -> dict:
1064        """
1065        This method is used to record or retrieve exchange rates for a specific account.
1066
1067        Parameters:
1068        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1069        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1070        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1071        - description (str): A description of the exchange rate.
1072
1073        Returns:
1074        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1075        it returns a dictionary with default values for the rate and description.
1076        """
1077        if debug:
1078            print('exchange', f'debug={debug}')
1079        if created is None:
1080            created = self.time()
1081        no_lock = self.nolock()
1082        self.lock()
1083        if rate is not None:
1084            if rate <= 0:
1085                return dict()
1086            if account not in self._vault['exchange']:
1087                self._vault['exchange'][account] = {}
1088            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1089                return {"time": created, "rate": 1, "description": None}
1090            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
1091            self._step(Action.EXCHANGE, account, ref=created, value=rate)
1092            if no_lock:
1093                self.free(self.lock())
1094            if debug:
1095                print("exchange-created-1",
1096                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1097
1098        if account in self._vault['exchange']:
1099            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
1100            if valid_rates:
1101                latest_rate = max(valid_rates, key=lambda x: x[0])
1102                if debug:
1103                    print("exchange-read-1",
1104                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
1105                          'latest_rate', latest_rate)
1106                result = latest_rate[1]
1107                result['time'] = latest_rate[0]
1108                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1109        if debug:
1110            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1111        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:
1113    @staticmethod
1114    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1115        """
1116        This function calculates the exchanged amount of a currency.
1117
1118        Args:
1119            x (float): The original amount of the currency.
1120            x_rate (float): The exchange rate of the original currency.
1121            y_rate (float): The exchange rate of the target currency.
1122
1123        Returns:
1124            float: The exchanged amount of the target currency.
1125        """
1126        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:
1128    def exchanges(self) -> dict:
1129        """
1130        Retrieve the recorded exchange rates for all accounts.
1131
1132        Parameters:
1133        None
1134
1135        Returns:
1136        dict: A dictionary containing all recorded exchange rates.
1137        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1138        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1139        """
1140        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:
1142    def accounts(self) -> dict:
1143        """
1144        Returns a dictionary containing account numbers as keys and their respective balances as values.
1145
1146        Parameters:
1147        None
1148
1149        Returns:
1150        dict: A dictionary where keys are account numbers and values are their respective balances.
1151        """
1152        result = {}
1153        for i in self._vault['account']:
1154            result[i] = self._vault['account'][i]['balance']
1155        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:
1157    def boxes(self, account) -> dict:
1158        """
1159        Retrieve the boxes (transactions) associated with a specific account.
1160
1161        Parameters:
1162        account (str): The account number for which to retrieve the boxes.
1163
1164        Returns:
1165        dict: A dictionary containing the boxes associated with the given account.
1166        If the account does not exist, an empty dictionary is returned.
1167        """
1168        if self.account_exists(account):
1169            return self._vault['account'][account]['box']
1170        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:
1172    def logs(self, account) -> dict:
1173        """
1174        Retrieve the logs (transactions) associated with a specific account.
1175
1176        Parameters:
1177        account (str): The account number for which to retrieve the logs.
1178
1179        Returns:
1180        dict: A dictionary containing the logs associated with the given account.
1181        If the account does not exist, an empty dictionary is returned.
1182        """
1183        if self.account_exists(account):
1184            return self._vault['account'][account]['log']
1185        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:
1187    def add_file(self, account: str, ref: int, path: str) -> int:
1188        """
1189        Adds a file reference to a specific transaction log entry in the vault.
1190
1191        Parameters:
1192        account (str): The account number associated with the transaction log.
1193        ref (int): The reference to the transaction log entry.
1194        path (str): The path of the file to be added.
1195
1196        Returns:
1197        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1198        """
1199        if self.account_exists(account):
1200            if ref in self._vault['account'][account]['log']:
1201                file_ref = self.time()
1202                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1203                no_lock = self.nolock()
1204                self.lock()
1205                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1206                if no_lock:
1207                    self.free(self.lock())
1208                return file_ref
1209        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:
1211    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1212        """
1213        Removes a file reference from a specific transaction log entry in the vault.
1214
1215        Parameters:
1216        account (str): The account number associated with the transaction log.
1217        ref (int): The reference to the transaction log entry.
1218        file_ref (int): The reference of the file to be removed.
1219
1220        Returns:
1221        bool: True if the file reference is successfully removed, False otherwise.
1222        """
1223        if self.account_exists(account):
1224            if ref in self._vault['account'][account]['log']:
1225                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1226                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1227                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1228                    no_lock = self.nolock()
1229                    self.lock()
1230                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1231                    if no_lock:
1232                        self.free(self.lock())
1233                    return True
1234        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:
1236    def balance(self, account: str = 1, cached: bool = True) -> int:
1237        """
1238        Calculate and return the balance of a specific account.
1239
1240        Parameters:
1241        account (str): The account number. Default is '1'.
1242        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1243
1244        Returns:
1245        int: The balance of the account.
1246
1247        Note:
1248        If cached is True, the function returns the cached balance.
1249        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1250        """
1251        if cached:
1252            return self._vault['account'][account]['balance']
1253        x = 0
1254        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:
1256    def hide(self, account, status: bool = None) -> bool:
1257        """
1258        Check or set the hide status of a specific account.
1259
1260        Parameters:
1261        account (str): The account number.
1262        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1263
1264        Returns:
1265        bool: The current or updated hide status of the account.
1266
1267        Raises:
1268        None
1269
1270        Example:
1271        >>> tracker = ZakatTracker()
1272        >>> ref = tracker.track(51, 'desc', 'account1')
1273        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1274        False
1275        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1276        True
1277        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1278        True
1279        >>> tracker.hide('account1', False)
1280        False
1281        """
1282        if self.account_exists(account):
1283            if status is None:
1284                return self._vault['account'][account]['hide']
1285            self._vault['account'][account]['hide'] = status
1286            return status
1287        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:
1289    def zakatable(self, account, status: bool = None) -> bool:
1290        """
1291        Check or set the zakatable status of a specific account.
1292
1293        Parameters:
1294        account (str): The account number.
1295        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1296
1297        Returns:
1298        bool: The current or updated zakatable status of the account.
1299
1300        Raises:
1301        None
1302
1303        Example:
1304        >>> tracker = ZakatTracker()
1305        >>> ref = tracker.track(51, 'desc', 'account1')
1306        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1307        True
1308        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1309        True
1310        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1311        True
1312        >>> tracker.zakatable('account1', False)
1313        False
1314        """
1315        if self.account_exists(account):
1316            if status is None:
1317                return self._vault['account'][account]['zakatable']
1318            self._vault['account'][account]['zakatable'] = status
1319            return status
1320        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:
1322    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1323        """
1324        Subtracts a specified value from an account's balance.
1325
1326        Parameters:
1327        x (float): The amount to be subtracted.
1328        desc (str): A description for the transaction. Defaults to an empty string.
1329        account (str): The account from which the value will be subtracted. Defaults to '1'.
1330        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1331        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1332
1333        Returns:
1334        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1335
1336        If the amount to subtract is greater than the account's balance,
1337        the remaining amount will be transferred to a new transaction with a negative value.
1338
1339        Raises:
1340        ValueError: The box transaction happened again in the same nanosecond time.
1341        ValueError: The log transaction happened again in the same nanosecond time.
1342        """
1343        if debug:
1344            print('sub', f'debug={debug}')
1345        if x < 0:
1346            return tuple()
1347        if x == 0:
1348            ref = self.track(x, '', account)
1349            return ref, ref
1350        if created is None:
1351            created = self.time()
1352        no_lock = self.nolock()
1353        self.lock()
1354        self.track(0, '', account)
1355        self._log(value=-x, desc=desc, account=account, created=created, ref=None, debug=debug)
1356        ids = sorted(self._vault['account'][account]['box'].keys())
1357        limit = len(ids) + 1
1358        target = x
1359        if debug:
1360            print('ids', ids)
1361        ages = []
1362        for i in range(-1, -limit, -1):
1363            if target == 0:
1364                break
1365            j = ids[i]
1366            if debug:
1367                print('i', i, 'j', j)
1368            rest = self._vault['account'][account]['box'][j]['rest']
1369            if rest >= target:
1370                self._vault['account'][account]['box'][j]['rest'] -= target
1371                self._step(Action.SUB, account, ref=j, value=target)
1372                ages.append((j, target))
1373                target = 0
1374                break
1375            elif target > rest > 0:
1376                chunk = rest
1377                target -= chunk
1378                self._step(Action.SUB, account, ref=j, value=chunk)
1379                ages.append((j, chunk))
1380                self._vault['account'][account]['box'][j]['rest'] = 0
1381        if target > 0:
1382            self.track(-target, desc, account, False, created)
1383            ages.append((created, target))
1384        if no_lock:
1385            self.free(self.lock())
1386        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]:
1388    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
1389                 debug: bool = False) -> list[int]:
1390        """
1391        Transfers a specified value from one account to another.
1392
1393        Parameters:
1394        amount (int): The amount to be transferred.
1395        from_account (str): The account from which the value will be transferred.
1396        to_account (str): The account to which the value will be transferred.
1397        desc (str, optional): A description for the transaction. Defaults to an empty string.
1398        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1399        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1400
1401        Returns:
1402        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1403
1404        Raises:
1405        ValueError: Transfer to the same account is forbidden.
1406        ValueError: The box transaction happened again in the same nanosecond time.
1407        ValueError: The log transaction happened again in the same nanosecond time.
1408        """
1409        if debug:
1410            print('transfer', f'debug={debug}')
1411        if from_account == to_account:
1412            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1413        if amount <= 0:
1414            return []
1415        if created is None:
1416            created = self.time()
1417        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
1418        times = []
1419        source_exchange = self.exchange(from_account, created)
1420        target_exchange = self.exchange(to_account, created)
1421
1422        if debug:
1423            print('ages', ages)
1424
1425        for age, value in ages:
1426            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
1427            # Perform the transfer
1428            if self.box_exists(to_account, age):
1429                if debug:
1430                    print('box_exists', age)
1431                capital = self._vault['account'][to_account]['box'][age]['capital']
1432                rest = self._vault['account'][to_account]['box'][age]['rest']
1433                if debug:
1434                    print(
1435                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1436                selected_age = age
1437                if rest + target_amount > capital:
1438                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1439                    selected_age = ZakatTracker.time()
1440                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1441                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1442                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1443                              created=None, ref=None, debug=debug)
1444                times.append((age, y))
1445                continue
1446            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1447            if debug:
1448                print(
1449                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1450            times.append(y)
1451        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:
1453    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1454              cycle: float = None) -> tuple:
1455        """
1456        Check the eligibility for Zakat based on the given parameters.
1457
1458        Parameters:
1459        silver_gram_price (float): The price of a gram of silver.
1460        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1461                        it will be calculated based on the silver_gram_price.
1462        debug (bool): Flag to enable debug mode.
1463        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1464        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1465
1466        Returns:
1467        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1468        and a dictionary containing the Zakat plan.
1469        """
1470        if debug:
1471            print('check', f'debug={debug}')
1472        if now is None:
1473            now = self.time()
1474        if cycle is None:
1475            cycle = ZakatTracker.TimeCycle()
1476        if nisab is None:
1477            nisab = ZakatTracker.Nisab(silver_gram_price)
1478        plan = {}
1479        below_nisab = 0
1480        brief = [0, 0, 0]
1481        valid = False
1482        if debug:
1483            print('exchanges', self.exchanges())
1484        for x in self._vault['account']:
1485            if not self.zakatable(x):
1486                continue
1487            _box = self._vault['account'][x]['box']
1488            _log = self._vault['account'][x]['log']
1489            limit = len(_box) + 1
1490            ids = sorted(self._vault['account'][x]['box'].keys())
1491            for i in range(-1, -limit, -1):
1492                j = ids[i]
1493                rest = float(_box[j]['rest'])
1494                if rest <= 0:
1495                    continue
1496                exchange = self.exchange(x, created=self.time())
1497                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1498                brief[0] += rest
1499                index = limit + i - 1
1500                epoch = (now - j) / cycle
1501                if debug:
1502                    print(f"Epoch: {epoch}", _box[j])
1503                if _box[j]['last'] > 0:
1504                    epoch = (now - _box[j]['last']) / cycle
1505                if debug:
1506                    print(f"Epoch: {epoch}")
1507                epoch = floor(epoch)
1508                if debug:
1509                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1510                if epoch == 0:
1511                    continue
1512                if debug:
1513                    print("Epoch - PASSED")
1514                brief[1] += rest
1515                if rest >= nisab:
1516                    total = 0
1517                    for _ in range(epoch):
1518                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1519                    if total > 0:
1520                        if x not in plan:
1521                            plan[x] = {}
1522                        valid = True
1523                        brief[2] += total
1524                        plan[x][index] = {
1525                            'total': total,
1526                            'count': epoch,
1527                            'box_time': j,
1528                            'box_capital': _box[j]['capital'],
1529                            'box_rest': _box[j]['rest'],
1530                            'box_last': _box[j]['last'],
1531                            'box_total': _box[j]['total'],
1532                            'box_count': _box[j]['count'],
1533                            'box_log': _log[j]['desc'],
1534                            'exchange_rate': exchange['rate'],
1535                            'exchange_time': exchange['time'],
1536                            'exchange_desc': exchange['description'],
1537                        }
1538                else:
1539                    chunk = ZakatTracker.ZakatCut(float(rest))
1540                    if chunk > 0:
1541                        if x not in plan:
1542                            plan[x] = {}
1543                        if j not in plan[x].keys():
1544                            plan[x][index] = {}
1545                        below_nisab += rest
1546                        brief[2] += chunk
1547                        plan[x][index]['below_nisab'] = chunk
1548                        plan[x][index]['total'] = chunk
1549                        plan[x][index]['count'] = epoch
1550                        plan[x][index]['box_time'] = j
1551                        plan[x][index]['box_capital'] = _box[j]['capital']
1552                        plan[x][index]['box_rest'] = _box[j]['rest']
1553                        plan[x][index]['box_last'] = _box[j]['last']
1554                        plan[x][index]['box_total'] = _box[j]['total']
1555                        plan[x][index]['box_count'] = _box[j]['count']
1556                        plan[x][index]['box_log'] = _log[j]['desc']
1557                        plan[x][index]['exchange_rate'] = exchange['rate']
1558                        plan[x][index]['exchange_time'] = exchange['time']
1559                        plan[x][index]['exchange_desc'] = exchange['description']
1560        valid = valid or below_nisab >= nisab
1561        if debug:
1562            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1563        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:
1565    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1566        """
1567        Build payment parts for the Zakat distribution.
1568
1569        Parameters:
1570        demand (float): The total demand for payment in local currency.
1571        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1572
1573        Returns:
1574        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1575        {
1576            'account': {
1577                'account_id': {'balance': float, 'rate': float, 'part': float},
1578                ...
1579            },
1580            'exceed': bool,
1581            'demand': float,
1582            'total': float,
1583        }
1584        """
1585        total = 0
1586        parts = {
1587            'account': {},
1588            'exceed': False,
1589            'demand': demand,
1590        }
1591        for x, y in self.accounts().items():
1592            if positive_only and y <= 0:
1593                continue
1594            total += float(y)
1595            exchange = self.exchange(x)
1596            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1597        parts['total'] = total
1598        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:
1600    @staticmethod
1601    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1602        """
1603        Checks the validity of payment parts.
1604
1605        Parameters:
1606        parts (dict): A dictionary containing payment parts information.
1607        debug (bool): Flag to enable debug mode.
1608
1609        Returns:
1610        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1611
1612        Error Codes:
1613        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1614        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1615        3: 'part' value in parts['account'][x] is less than 0.
1616        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1617        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1618        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1619        """
1620        if debug:
1621            print('check_payment_parts', f'debug={debug}')
1622        for i in ['demand', 'account', 'total', 'exceed']:
1623            if i not in parts:
1624                return 1
1625        exceed = parts['exceed']
1626        for x in parts['account']:
1627            for j in ['balance', 'rate', 'part']:
1628                if j not in parts['account'][x]:
1629                    return 2
1630                if parts['account'][x]['part'] < 0:
1631                    return 3
1632                if not exceed and parts['account'][x]['balance'] <= 0:
1633                    return 4
1634        demand = parts['demand']
1635        z = 0
1636        for _, y in parts['account'].items():
1637            if not exceed and y['part'] > y['balance']:
1638                return 5
1639            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1640        z = round(z, 2)
1641        demand = round(demand, 2)
1642        if debug:
1643            print('check_payment_parts', f'z = {z}, demand = {demand}')
1644            print('check_payment_parts', type(z), type(demand))
1645            print('check_payment_parts', z != demand)
1646            print('check_payment_parts', str(z) != str(demand))
1647        if z != demand and str(z) != str(demand):
1648            return 6
1649        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:
1651    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1652        """
1653        Perform Zakat calculation based on the given report and optional parts.
1654
1655        Parameters:
1656        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1657        parts (dict): A dictionary containing the payment parts for the zakat.
1658        debug (bool): A flag indicating whether to print debug information.
1659
1660        Returns:
1661        bool: True if the zakat calculation is successful, False otherwise.
1662        """
1663        if debug:
1664            print('zakat', f'debug={debug}')
1665        valid, _, plan = report
1666        if not valid:
1667            return valid
1668        parts_exist = parts is not None
1669        if parts_exist:
1670            if self.check_payment_parts(parts, debug=debug) != 0:
1671                return False
1672        if debug:
1673            print('######### zakat #######')
1674            print('parts_exist', parts_exist)
1675        no_lock = self.nolock()
1676        self.lock()
1677        report_time = self.time()
1678        self._vault['report'][report_time] = report
1679        self._step(Action.REPORT, ref=report_time)
1680        created = self.time()
1681        for x in plan:
1682            target_exchange = self.exchange(x)
1683            if debug:
1684                print(plan[x])
1685                print('-------------')
1686                print(self._vault['account'][x]['box'])
1687            ids = sorted(self._vault['account'][x]['box'].keys())
1688            if debug:
1689                print('plan[x]', plan[x])
1690            for i in plan[x].keys():
1691                j = ids[i]
1692                if debug:
1693                    print('i', i, 'j', j)
1694                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1695                           key='last',
1696                           math_operation=MathOperation.EQUAL)
1697                self._vault['account'][x]['box'][j]['last'] = created
1698                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1699                self._vault['account'][x]['box'][j]['total'] += amount
1700                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1701                           math_operation=MathOperation.ADDITION)
1702                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1703                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1704                           math_operation=MathOperation.ADDITION)
1705                if not parts_exist:
1706                    try:
1707                        self._vault['account'][x]['box'][j]['rest'] -= amount
1708                    except TypeError:
1709                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1710                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1711                    #            math_operation=MathOperation.SUBTRACTION)
1712                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1713        if parts_exist:
1714            for account, part in parts['account'].items():
1715                if part['part'] == 0:
1716                    continue
1717                if debug:
1718                    print('zakat-part', account, part['rate'])
1719                target_exchange = self.exchange(account)
1720                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1721                self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug)
1722        if no_lock:
1723            self.free(self.lock())
1724        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:
1726    def export_json(self, path: str = "data.json") -> bool:
1727        """
1728        Exports the current state of the ZakatTracker object to a JSON file.
1729
1730        Parameters:
1731        path (str): The path where the JSON file will be saved. Default is "data.json".
1732
1733        Returns:
1734        bool: True if the export is successful, False otherwise.
1735
1736        Raises:
1737        No specific exceptions are raised by this method.
1738        """
1739        with open(path, "w") as file:
1740            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1741            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:
1743    def save(self, path: str = None) -> bool:
1744        """
1745        Saves the ZakatTracker's current state to a pickle file.
1746
1747        This method serializes the internal data (`_vault`) along with metadata
1748        (Python version, pickle protocol) for future compatibility.
1749
1750        Parameters:
1751        path (str, optional): File path for saving. Defaults to a predefined location.
1752
1753        Returns:
1754        bool: True if the save operation is successful, False otherwise.
1755        """
1756        if path is None:
1757            path = self.path()
1758        with open(path, "wb") as f:
1759            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1760            pickle_protocol = pickle.HIGHEST_PROTOCOL
1761            data = {
1762                'python_version': version,
1763                'pickle_protocol': pickle_protocol,
1764                'data': self._vault,
1765            }
1766            pickle.dump(data, f, protocol=pickle_protocol)
1767            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:
1769    def load(self, path: str = None) -> bool:
1770        """
1771        Load the current state of the ZakatTracker object from a pickle file.
1772
1773        Parameters:
1774        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1775
1776        Returns:
1777        bool: True if the load operation is successful, False otherwise.
1778        """
1779        if path is None:
1780            path = self.path()
1781        if os.path.exists(path):
1782            with open(path, "rb") as f:
1783                data = pickle.load(f)
1784                self._vault = data['data']
1785                return True
1786        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):
1788    def import_csv_cache_path(self):
1789        """
1790        Generates the cache file path for imported CSV data.
1791
1792        This function constructs the file path where cached data from CSV imports
1793        will be stored. The cache file is a pickle file (.pickle extension) appended
1794        to the base path of the object.
1795
1796        Returns:
1797        str: The full path to the import CSV cache file.
1798
1799        Example:
1800            >>> obj = ZakatTracker('/data/reports')
1801            >>> obj.import_csv_cache_path()
1802            '/data/reports.import_csv.pickle'
1803        """
1804        path = str(self.path())
1805        if path.endswith(".pickle"):
1806            path = path[:-7]
1807        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:
1809    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1810        """
1811        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1812
1813        Parameters:
1814        path (str): The path to the CSV file. Default is 'file.csv'.
1815        debug (bool): A flag indicating whether to print debug information.
1816
1817        Returns:
1818        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1819                and a dictionary of bad transactions.
1820
1821        Notes:
1822            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1823                                        are appropriate for the currency pairs involved in the conversions.
1824            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1825                to 1.0 or the previous rate for that account.
1826            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1827              transactions of the same account within the whole imported and existing dataset when doing `check` and
1828              `zakat` operations.
1829
1830        Example Usage:
1831            The CSV file should have the following format, rate is optional per transaction:
1832            account, desc, value, date, rate
1833            For example:
1834            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1835        """
1836        if debug:
1837            print('import_csv', f'debug={debug}')
1838        cache: list[int] = []
1839        try:
1840            with open(self.import_csv_cache_path(), "rb") as f:
1841                cache = pickle.load(f)
1842        except:
1843            pass
1844        date_formats = [
1845            "%Y-%m-%d %H:%M:%S",
1846            "%Y-%m-%dT%H:%M:%S",
1847            "%Y-%m-%dT%H%M%S",
1848            "%Y-%m-%d",
1849        ]
1850        created, found, bad = 0, 0, {}
1851        data: dict[int, list] = {}
1852        with open(path, newline='', encoding="utf-8") as f:
1853            i = 0
1854            for row in csv.reader(f, delimiter=','):
1855                i += 1
1856                hashed = hash(tuple(row))
1857                if hashed in cache:
1858                    found += 1
1859                    continue
1860                account = row[0]
1861                desc = row[1]
1862                value = float(row[2])
1863                rate = 1.0
1864                if row[4:5]:  # Empty list if index is out of range
1865                    rate = float(row[4])
1866                date: int = 0
1867                for time_format in date_formats:
1868                    try:
1869                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1870                        break
1871                    except:
1872                        pass
1873                # TODO: not allowed for negative dates in the future after enhance time functions
1874                if date == 0 or value == 0:
1875                    bad[i] = row + ('invalid date',)
1876                    continue
1877                if date not in data:
1878                    data[date] = []
1879                data[date].append((i, account, desc, value, date, rate, hashed))
1880
1881        if debug:
1882            print('import_csv', len(data))
1883
1884        if bad:
1885            return created, found, bad
1886
1887        for date, rows in sorted(data.items()):
1888            try:
1889                len_rows = len(rows)
1890                if len_rows == 1:
1891                    (_, account, desc, value, date, rate, hashed) = rows[0]
1892                    if rate > 0:
1893                        self.exchange(account, created=date, rate=rate)
1894                    if value > 0:
1895                        self.track(value, desc, account, True, date)
1896                    elif value < 0:
1897                        self.sub(-value, desc, account, date)
1898                    created += 1
1899                    cache.append(hashed)
1900                    continue
1901                if debug:
1902                    print('-- Duplicated time detected', date, 'len', len_rows)
1903                    print(rows)
1904                    print('---------------------------------')
1905                # If records are found at the same time with different accounts in the same amount
1906                # (one positive and the other negative), this indicates it is a transfer.
1907                if len_rows != 2:
1908                    raise Exception(f'more than two transactions({len_rows}) at the same time')
1909                (i, account1, desc1, value1, date1, rate1, _) = rows[0]
1910                (j, account2, desc2, value2, date2, rate2, _) = rows[1]
1911                if account1 == account2 or desc1 != desc2 or abs(value1) != abs(value2) or date1 != date2:
1912                    raise Exception('invalid transfer')
1913                if rate1 > 0:
1914                    self.exchange(account1, created=date1, rate=rate1)
1915                if rate2 > 0:
1916                    self.exchange(account2, created=date2, rate=rate2)
1917                values = {
1918                    value1: account1,
1919                    value2: account2,
1920                }
1921                self.transfer(
1922                    amount=abs(value1),
1923                    from_account=values[min(values.keys())],
1924                    to_account=values[max(values.keys())],
1925                    desc=desc1,
1926                    created=date1,
1927                )
1928            except Exception as e:
1929                for (i, account, desc, value, date, rate, _) in rows:
1930                    bad[i] = (account, desc, value, date, rate, e)
1931                break
1932        with open(self.import_csv_cache_path(), "wb") as file:
1933            pickle.dump(cache, file)
1934        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:
1940    @staticmethod
1941    def human_readable_size(size: float, decimal_places: int = 2) -> str:
1942        """
1943        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
1944
1945        This function iterates through progressively larger units of information
1946        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
1947        range that can be expressed with a reasonable number before the unit.
1948
1949        Parameters:
1950        size (float): The size in bytes to convert.
1951        decimal_places (int, optional): The number of decimal places to display
1952            in the result. Defaults to 2.
1953
1954        Returns:
1955        str: A string representation of the size in a human-readable format,
1956            rounded to the specified number of decimal places. For example:
1957                - "1.50 KB" (1536 bytes)
1958                - "23.00 MB" (24117248 bytes)
1959                - "1.23 GB" (1325899906 bytes)
1960        """
1961        if type(size) not in (float, int):
1962            raise TypeError("size must be a float or integer")
1963        if type(decimal_places) != int:
1964            raise TypeError("decimal_places must be an integer")
1965        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
1966            if size < 1024.0:
1967                break
1968            size /= 1024.0
1969        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:
1971    @staticmethod
1972    def get_dict_size(obj: dict, seen: set = None) -> float:
1973        """
1974        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
1975
1976        This function traverses the dictionary structure, accounting for the size of keys, values,
1977        and any nested objects. It handles various data types commonly found in dictionaries
1978        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
1979        of circular references.
1980
1981        Parameters:
1982        obj (dict): The dictionary whose size is to be calculated.
1983        seen (set, optional): A set used internally to track visited objects
1984                             and avoid circular references. Defaults to None.
1985
1986        Returns:
1987            float: An approximate size of the dictionary and its contents in bytes.
1988
1989        Note:
1990        - This function is a method of the `ZakatTracker` class and is likely used to
1991          estimate the memory footprint of data structures relevant to Zakat calculations.
1992        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
1993          not account for all memory overhead depending on the Python implementation.
1994        - Circular references are handled to prevent infinite recursion.
1995        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
1996        - String sizes are estimated based on character length and encoding.
1997        """
1998        size = 0
1999        if seen is None:
2000            seen = set()
2001
2002        obj_id = id(obj)
2003        if obj_id in seen:
2004            return 0
2005
2006        seen.add(obj_id)
2007        size += sys.getsizeof(obj)
2008
2009        if isinstance(obj, dict):
2010            for k, v in obj.items():
2011                size += ZakatTracker.get_dict_size(k, seen)
2012                size += ZakatTracker.get_dict_size(v, seen)
2013        elif isinstance(obj, (list, tuple, set, frozenset)):
2014            for item in obj:
2015                size += ZakatTracker.get_dict_size(item, seen)
2016        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2017            pass  # Basic numbers have a fixed size, so nothing to add here
2018        elif isinstance(obj, str):  # Handle strings
2019            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2020        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:
2022    @staticmethod
2023    def duration_from_nanoseconds(ns: int,
2024                                  show_zeros_in_spoken_time: bool = False,
2025                                  spoken_time_separator=',',
2026                                  millennia: str = 'Millennia',
2027                                  century: str = 'Century',
2028                                  years: str = 'Years',
2029                                  days: str = 'Days',
2030                                  hours: str = 'Hours',
2031                                  minutes: str = 'Minutes',
2032                                  seconds: str = 'Seconds',
2033                                  milli_seconds: str = 'MilliSeconds',
2034                                  micro_seconds: str = 'MicroSeconds',
2035                                  nano_seconds: str = 'NanoSeconds',
2036                                  ) -> tuple:
2037        """
2038        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2039        Convert NanoSeconds to Human Readable Time Format.
2040        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2041        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2042        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2043        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2044
2045        INPUT : ms (AKA: MilliSeconds)
2046        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2047        OUTPUT Variables: time_lapsed, spoken_time
2048
2049        Example  Input: duration_from_nanoseconds(ns)
2050        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
2051        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')
2052        duration_from_nanoseconds(1234567890123456789012)
2053        """
2054        us, ns = divmod(ns, 1000)
2055        ms, us = divmod(us, 1000)
2056        s, ms = divmod(ms, 1000)
2057        m, s = divmod(s, 60)
2058        h, m = divmod(m, 60)
2059        d, h = divmod(h, 24)
2060        y, d = divmod(d, 365)
2061        c, y = divmod(y, 100)
2062        n, c = divmod(c, 10)
2063        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}"
2064        spoken_time_part = []
2065        if n > 0 or show_zeros_in_spoken_time:
2066            spoken_time_part.append(f"{n: 3d} {millennia}")
2067        if c > 0 or show_zeros_in_spoken_time:
2068            spoken_time_part.append(f"{c: 4d} {century}")
2069        if y > 0 or show_zeros_in_spoken_time:
2070            spoken_time_part.append(f"{y: 3d} {years}")
2071        if d > 0 or show_zeros_in_spoken_time:
2072            spoken_time_part.append(f"{d: 4d} {days}")
2073        if h > 0 or show_zeros_in_spoken_time:
2074            spoken_time_part.append(f"{h: 2d} {hours}")
2075        if m > 0 or show_zeros_in_spoken_time:
2076            spoken_time_part.append(f"{m: 2d} {minutes}")
2077        if s > 0 or show_zeros_in_spoken_time:
2078            spoken_time_part.append(f"{s: 2d} {seconds}")
2079        if ms > 0 or show_zeros_in_spoken_time:
2080            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
2081        if us > 0 or show_zeros_in_spoken_time:
2082            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
2083        if ns > 0 or show_zeros_in_spoken_time:
2084            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
2085        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:
2087    @staticmethod
2088    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2089        """
2090        Convert a specific day, month, and year into a timestamp.
2091
2092        Parameters:
2093        day (int): The day of the month.
2094        month (int): The month of the year. Default is 6 (June).
2095        year (int): The year. Default is 2024.
2096
2097        Returns:
2098        int: The timestamp representing the given day, month, and year.
2099
2100        Note:
2101        This method assumes the default month and year if not provided.
2102        """
2103        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:
2105    @staticmethod
2106    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2107        """
2108        Generate a random date between two given dates.
2109
2110        Parameters:
2111        start_date (datetime.datetime): The start date from which to generate a random date.
2112        end_date (datetime.datetime): The end date until which to generate a random date.
2113
2114        Returns:
2115        datetime.datetime: A random date between the start_date and end_date.
2116        """
2117        time_between_dates = end_date - start_date
2118        days_between_dates = time_between_dates.days
2119        random_number_of_days = random.randrange(days_between_dates)
2120        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:
2122    @staticmethod
2123    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
2124                                 debug: bool = False) -> int:
2125        """
2126        Generate a random CSV file with specified parameters.
2127
2128        Parameters:
2129        path (str): The path where the CSV file will be saved. Default is "data.csv".
2130        count (int): The number of rows to generate in the CSV file. Default is 1000.
2131        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
2132        debug (bool): A flag indicating whether to print debug information.
2133
2134        Returns:
2135        None. The function generates a CSV file at the specified path with the given count of rows.
2136        Each row contains a randomly generated account, description, value, and date.
2137        The value is randomly generated between 1000 and 100000,
2138        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2139        If the row number is not divisible by 13, the value is multiplied by -1.
2140        """
2141        if debug:
2142            print('generate_random_csv_file', f'debug={debug}')
2143        i = 0
2144        with open(path, "w", newline="") as csvfile:
2145            writer = csv.writer(csvfile)
2146            for i in range(count):
2147                account = f"acc-{random.randint(1, 1000)}"
2148                desc = f"Some text {random.randint(1, 1000)}"
2149                value = random.randint(1000, 100000)
2150                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2151                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
2152                if not i % 13 == 0:
2153                    value *= -1
2154                row = [account, desc, value, date]
2155                if with_rate:
2156                    rate = random.randint(1, 100) * 0.12
2157                    if debug:
2158                        print('before-append', row)
2159                    row.append(rate)
2160                    if debug:
2161                        print('after-append', row)
2162                writer.writerow(row)
2163                i = i + 1
2164        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):
2166    @staticmethod
2167    def create_random_list(max_sum, min_value=0, max_value=10):
2168        """
2169        Creates a list of random integers whose sum does not exceed the specified maximum.
2170
2171        Args:
2172            max_sum: The maximum allowed sum of the list elements.
2173            min_value: The minimum possible value for an element (inclusive).
2174            max_value: The maximum possible value for an element (inclusive).
2175
2176        Returns:
2177            A list of random integers.
2178        """
2179        result = []
2180        current_sum = 0
2181
2182        while current_sum < max_sum:
2183            # Calculate the remaining space for the next element
2184            remaining_sum = max_sum - current_sum
2185            # Determine the maximum possible value for the next element
2186            next_max_value = min(remaining_sum, max_value)
2187            # Generate a random element within the allowed range
2188            next_element = random.randint(min_value, next_max_value)
2189            result.append(next_element)
2190            current_sum += next_element
2191
2192        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:
2405    def test(self, debug: bool = False) -> bool:
2406        if debug:
2407            print('test', f'debug={debug}')
2408        try:
2409
2410            assert self._history()
2411
2412            # Not allowed for duplicate transactions in the same account and time
2413
2414            created = ZakatTracker.time()
2415            self.track(100, 'test-1', 'same', True, created)
2416            failed = False
2417            try:
2418                self.track(50, 'test-1', 'same', True, created)
2419            except:
2420                failed = True
2421            assert failed is True
2422
2423            self.reset()
2424
2425            # Same account transfer
2426            for x in [1, 'a', True, 1.8, None]:
2427                failed = False
2428                try:
2429                    self.transfer(1, x, x, 'same-account', debug=debug)
2430                except:
2431                    failed = True
2432                assert failed is True
2433
2434            # Always preserve box age during transfer
2435
2436            series: list[tuple] = [
2437                (30, 4),
2438                (60, 3),
2439                (90, 2),
2440            ]
2441            case = {
2442                30: {
2443                    'series': series,
2444                    'rest': 150,
2445                },
2446                60: {
2447                    'series': series,
2448                    'rest': 120,
2449                },
2450                90: {
2451                    'series': series,
2452                    'rest': 90,
2453                },
2454                180: {
2455                    'series': series,
2456                    'rest': 0,
2457                },
2458                270: {
2459                    'series': series,
2460                    'rest': -90,
2461                },
2462                360: {
2463                    'series': series,
2464                    'rest': -180,
2465                },
2466            }
2467
2468            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2469
2470            for total in case:
2471                for x in case[total]['series']:
2472                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
2473
2474                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
2475
2476                if debug:
2477                    print('refs', refs)
2478
2479                ages_cache_balance = self.balance('ages')
2480                ages_fresh_balance = self.balance('ages', False)
2481                rest = case[total]['rest']
2482                if debug:
2483                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2484                assert ages_cache_balance == rest
2485                assert ages_fresh_balance == rest
2486
2487                future_cache_balance = self.balance('future')
2488                future_fresh_balance = self.balance('future', False)
2489                if debug:
2490                    print('target', future_cache_balance, future_fresh_balance, total)
2491                    print('refs', refs)
2492                assert future_cache_balance == total
2493                assert future_fresh_balance == total
2494
2495                for ref in self._vault['account']['ages']['box']:
2496                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2497                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2498                    future_capital = 0
2499                    if ref in self._vault['account']['future']['box']:
2500                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2501                    future_rest = 0
2502                    if ref in self._vault['account']['future']['box']:
2503                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2504                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2505                        if debug:
2506                            print('================================================================')
2507                            print('ages', ages_capital, ages_rest)
2508                            print('future', future_capital, future_rest)
2509                        if ages_rest == 0:
2510                            assert ages_capital == future_capital
2511                        elif ages_rest < 0:
2512                            assert -ages_capital == future_capital
2513                        elif ages_rest > 0:
2514                            assert ages_capital == ages_rest + future_capital
2515                self.reset()
2516                assert len(self._vault['history']) == 0
2517
2518            assert self._history()
2519            assert self._history(False) is False
2520            assert self._history() is False
2521            assert self._history(True)
2522            assert self._history()
2523
2524            self._test_core(True, debug)
2525            self._test_core(False, debug)
2526
2527            transaction = [
2528                (
2529                    20, 'wallet', 1, 800, 800, 800, 4, 5,
2530                    -85, -85, -85, 6, 7,
2531                ),
2532                (
2533                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
2534                    750, 750, 750, 1, 1,
2535                ),
2536                (
2537                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
2538                    600, 600, 600, 1, 1,
2539                ),
2540            ]
2541            for z in transaction:
2542                self.lock()
2543                x = z[1]
2544                y = z[2]
2545                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
2546                assert self.balance(x) == z[3]
2547                xx = self.accounts()[x]
2548                assert xx == z[3]
2549                assert self.balance(x, False) == z[4]
2550                assert xx == z[4]
2551
2552                s = 0
2553                log = self._vault['account'][x]['log']
2554                for i in log:
2555                    s += log[i]['value']
2556                if debug:
2557                    print('s', s, 'z[5]', z[5])
2558                assert s == z[5]
2559
2560                assert self.box_size(x) == z[6]
2561                assert self.log_size(x) == z[7]
2562
2563                yy = self.accounts()[y]
2564                assert self.balance(y) == z[8]
2565                assert yy == z[8]
2566                assert self.balance(y, False) == z[9]
2567                assert yy == z[9]
2568
2569                s = 0
2570                log = self._vault['account'][y]['log']
2571                for i in log:
2572                    s += log[i]['value']
2573                assert s == z[10]
2574
2575                assert self.box_size(y) == z[11]
2576                assert self.log_size(y) == z[12]
2577
2578            if debug:
2579                pp().pprint(self.check(2.17))
2580
2581            assert not self.nolock()
2582            history_count = len(self._vault['history'])
2583            if debug:
2584                print('history-count', history_count)
2585            assert history_count == 11
2586            assert not self.free(ZakatTracker.time())
2587            assert self.free(self.lock())
2588            assert self.nolock()
2589            assert len(self._vault['history']) == 11
2590
2591            # storage
2592
2593            _path = self.path('test.pickle')
2594            if os.path.exists(_path):
2595                os.remove(_path)
2596            self.save()
2597            assert os.path.getsize(_path) > 0
2598            self.reset()
2599            assert self.recall(False, debug) is False
2600            self.load()
2601            assert self._vault['account'] is not None
2602
2603            # recall
2604
2605            assert self.nolock()
2606            assert len(self._vault['history']) == 11
2607            assert self.recall(False, debug) is True
2608            assert len(self._vault['history']) == 10
2609            assert self.recall(False, debug) is True
2610            assert len(self._vault['history']) == 9
2611
2612            # exchange
2613
2614            self.exchange("cash", 25, 3.75, "2024-06-25")
2615            self.exchange("cash", 22, 3.73, "2024-06-22")
2616            self.exchange("cash", 15, 3.69, "2024-06-15")
2617            self.exchange("cash", 10, 3.66)
2618
2619            for i in range(1, 30):
2620                exchange = self.exchange("cash", i)
2621                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2622                if debug:
2623                    print(i, rate, description, created)
2624                assert created
2625                if i < 10:
2626                    assert rate == 1
2627                    assert description is None
2628                elif i == 10:
2629                    assert rate == 3.66
2630                    assert description is None
2631                elif i < 15:
2632                    assert rate == 3.66
2633                    assert description is None
2634                elif i == 15:
2635                    assert rate == 3.69
2636                    assert description is not None
2637                elif i < 22:
2638                    assert rate == 3.69
2639                    assert description is not None
2640                elif i == 22:
2641                    assert rate == 3.73
2642                    assert description is not None
2643                elif i >= 25:
2644                    assert rate == 3.75
2645                    assert description is not None
2646                exchange = self.exchange("bank", i)
2647                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2648                if debug:
2649                    print(i, rate, description, created)
2650                assert created
2651                assert rate == 1
2652                assert description is None
2653
2654            assert len(self._vault['exchange']) > 0
2655            assert len(self.exchanges()) > 0
2656            self._vault['exchange'].clear()
2657            assert len(self._vault['exchange']) == 0
2658            assert len(self.exchanges()) == 0
2659
2660            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2661            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2662            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2663            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2664            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2665
2666            for i in [x * 0.12 for x in range(-15, 21)]:
2667                if i <= 0:
2668                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2669                else:
2670                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2671
2672            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2673            for i in range(1, 31):
2674                timestamp_ns = ZakatTracker.day_to_time(i)
2675                exchange = self.exchange("cash", timestamp_ns)
2676                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2677                if debug:
2678                    print(i, rate, description, created)
2679                assert created
2680                if i < 10:
2681                    assert rate == 1
2682                    assert description is None
2683                elif i == 10:
2684                    assert rate == 3.66
2685                    assert description is None
2686                elif i < 15:
2687                    assert rate == 3.66
2688                    assert description is None
2689                elif i == 15:
2690                    assert rate == 3.69
2691                    assert description is not None
2692                elif i < 22:
2693                    assert rate == 3.69
2694                    assert description is not None
2695                elif i == 22:
2696                    assert rate == 3.73
2697                    assert description is not None
2698                elif i >= 25:
2699                    assert rate == 3.75
2700                    assert description is not None
2701                exchange = self.exchange("bank", i)
2702                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2703                if debug:
2704                    print(i, rate, description, created)
2705                assert created
2706                assert rate == 1
2707                assert description is None
2708
2709            # csv
2710
2711            csv_count = 1000
2712
2713            for with_rate, path in {
2714                False: 'test-import_csv-no-exchange',
2715                True: 'test-import_csv-with-exchange',
2716            }.items():
2717
2718                if debug:
2719                    print('test_import_csv', with_rate, path)
2720
2721                # csv
2722
2723                csv_path = path + '.csv'
2724                if os.path.exists(csv_path):
2725                    os.remove(csv_path)
2726                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2727                if debug:
2728                    print('generate_random_csv_file', c)
2729                assert c == csv_count
2730                assert os.path.getsize(csv_path) > 0
2731                cache_path = self.import_csv_cache_path()
2732                if os.path.exists(cache_path):
2733                    os.remove(cache_path)
2734                self.reset()
2735                (created, found, bad) = self.import_csv(csv_path, debug)
2736                bad_count = len(bad)
2737                assert bad_count > 0
2738                if debug:
2739                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2740                    print('bad', bad)
2741                tmp_size = os.path.getsize(cache_path)
2742                assert tmp_size > 0
2743                # TODO: assert created + found + bad_count == csv_count
2744                # TODO: assert created == csv_count
2745                # TODO: assert bad_count == 0
2746                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2747                bad_2_count = len(bad_2)
2748                if debug:
2749                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2750                    print('bad', bad)
2751                assert bad_2_count > 0
2752                # TODO: assert tmp_size == os.path.getsize(cache_path)
2753                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
2754                # TODO: assert created == found_2
2755                # TODO: assert bad_count == bad_2_count
2756                # TODO: assert found_2 == csv_count
2757                # TODO: assert bad_2_count == 0
2758                # TODO: assert created_2 == 0
2759
2760                # payment parts
2761
2762                positive_parts = self.build_payment_parts(100, positive_only=True)
2763                assert self.check_payment_parts(positive_parts) != 0
2764                assert self.check_payment_parts(positive_parts) != 0
2765                all_parts = self.build_payment_parts(300, positive_only=False)
2766                assert self.check_payment_parts(all_parts) != 0
2767                assert self.check_payment_parts(all_parts) != 0
2768                if debug:
2769                    pp().pprint(positive_parts)
2770                    pp().pprint(all_parts)
2771                # dynamic discount
2772                suite = []
2773                count = 3
2774                for exceed in [False, True]:
2775                    case = []
2776                    for parts in [positive_parts, all_parts]:
2777                        part = parts.copy()
2778                        demand = part['demand']
2779                        if debug:
2780                            print(demand, part['total'])
2781                        i = 0
2782                        z = demand / count
2783                        cp = {
2784                            'account': {},
2785                            'demand': demand,
2786                            'exceed': exceed,
2787                            'total': part['total'],
2788                        }
2789                        j = ''
2790                        for x, y in part['account'].items():
2791                            x_exchange = self.exchange(x)
2792                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2793                            if exceed and zz <= demand:
2794                                i += 1
2795                                y['part'] = zz
2796                                if debug:
2797                                    print(exceed, y)
2798                                cp['account'][x] = y
2799                                case.append(y)
2800                            elif not exceed and y['balance'] >= zz:
2801                                i += 1
2802                                y['part'] = zz
2803                                if debug:
2804                                    print(exceed, y)
2805                                cp['account'][x] = y
2806                                case.append(y)
2807                            j = x
2808                            if i >= count:
2809                                break
2810                        if len(cp['account'][j]) > 0:
2811                            suite.append(cp)
2812                if debug:
2813                    print('suite', len(suite))
2814                # vault = self._vault.copy()
2815                for case in suite:
2816                    # self._vault = vault.copy()
2817                    if debug:
2818                        print('case', case)
2819                    result = self.check_payment_parts(case)
2820                    if debug:
2821                        print('check_payment_parts', result, f'exceed: {exceed}')
2822                    assert result == 0
2823
2824                    report = self.check(2.17, None, debug)
2825                    (valid, brief, plan) = report
2826                    if debug:
2827                        print('valid', valid)
2828                    zakat_result = self.zakat(report, parts=case, debug=debug)
2829                    if debug:
2830                        print('zakat-result', zakat_result)
2831                    assert valid == zakat_result
2832
2833            assert self.save(path + '.pickle')
2834            assert self.export_json(path + '.json')
2835
2836            assert self.export_json("1000-transactions-test.json")
2837            assert self.save("1000-transactions-test.pickle")
2838
2839            self.reset()
2840
2841            # test transfer between accounts with different exchange rate
2842
2843            a_SAR = "Bank (SAR)"
2844            b_USD = "Bank (USD)"
2845            c_SAR = "Safe (SAR)"
2846            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2847            for case in [
2848                (0, a_SAR, "SAR Gift", 1000, 1000),
2849                (1, a_SAR, 1),
2850                (0, b_USD, "USD Gift", 500, 500),
2851                (1, b_USD, 1),
2852                (2, b_USD, 3.75),
2853                (1, b_USD, 3.75),
2854                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2855                (0, c_SAR, "Salary", 750, 750),
2856                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2857                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2858            ]:
2859                match (case[0]):
2860                    case 0:  # track
2861                        _, account, desc, x, balance = case
2862                        self.track(value=x, desc=desc, account=account, debug=debug)
2863
2864                        cached_value = self.balance(account, cached=True)
2865                        fresh_value = self.balance(account, cached=False)
2866                        if debug:
2867                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2868                        assert cached_value == balance
2869                        assert fresh_value == balance
2870                    case 1:  # check-exchange
2871                        _, account, expected_rate = case
2872                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2873                        if debug:
2874                            print('t-exchange', t_exchange)
2875                        assert t_exchange['rate'] == expected_rate
2876                    case 2:  # do-exchange
2877                        _, account, rate = case
2878                        self.exchange(account, rate=rate, debug=debug)
2879                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2880                        if debug:
2881                            print('b-exchange', b_exchange)
2882                        assert b_exchange['rate'] == rate
2883                    case 3:  # transfer
2884                        _, x, a, b, desc, a_balance, b_balance = case
2885                        self.transfer(x, a, b, desc, debug=debug)
2886
2887                        cached_value = self.balance(a, cached=True)
2888                        fresh_value = self.balance(a, cached=False)
2889                        if debug:
2890                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2891                        assert cached_value == a_balance
2892                        assert fresh_value == a_balance
2893
2894                        cached_value = self.balance(b, cached=True)
2895                        fresh_value = self.balance(b, cached=False)
2896                        if debug:
2897                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2898                        assert cached_value == b_balance
2899                        assert fresh_value == b_balance
2900
2901            # Transfer all in many chunks randomly from B to A
2902            a_SAR_balance = 1371.25
2903            b_USD_balance = 501
2904            b_USD_exchange = self.exchange(b_USD)
2905            amounts = ZakatTracker.create_random_list(b_USD_balance)
2906            if debug:
2907                print('amounts', amounts)
2908            i = 0
2909            for x in amounts:
2910                if debug:
2911                    print(f'{i} - transfer-with-exchange({x})')
2912                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2913
2914                b_USD_balance -= x
2915                cached_value = self.balance(b_USD, cached=True)
2916                fresh_value = self.balance(b_USD, cached=False)
2917                if debug:
2918                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2919                          b_USD_balance)
2920                assert cached_value == b_USD_balance
2921                assert fresh_value == b_USD_balance
2922
2923                a_SAR_balance += x * b_USD_exchange['rate']
2924                cached_value = self.balance(a_SAR, cached=True)
2925                fresh_value = self.balance(a_SAR, cached=False)
2926                if debug:
2927                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2928                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2929                assert cached_value == a_SAR_balance
2930                assert fresh_value == a_SAR_balance
2931                i += 1
2932
2933            # Transfer all in many chunks randomly from C to A
2934            c_SAR_balance = 375
2935            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2936            if debug:
2937                print('amounts', amounts)
2938            i = 0
2939            for x in amounts:
2940                if debug:
2941                    print(f'{i} - transfer-with-exchange({x})')
2942                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2943
2944                c_SAR_balance -= x
2945                cached_value = self.balance(c_SAR, cached=True)
2946                fresh_value = self.balance(c_SAR, cached=False)
2947                if debug:
2948                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2949                          c_SAR_balance)
2950                assert cached_value == c_SAR_balance
2951                assert fresh_value == c_SAR_balance
2952
2953                a_SAR_balance += x
2954                cached_value = self.balance(a_SAR, cached=True)
2955                fresh_value = self.balance(a_SAR, cached=False)
2956                if debug:
2957                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2958                          a_SAR_balance)
2959                assert cached_value == a_SAR_balance
2960                assert fresh_value == a_SAR_balance
2961                i += 1
2962
2963            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2964            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2965
2966            # check & zakat with exchange rates for many cycles
2967
2968            for rate, values in {
2969                1: {
2970                    'in': [1000, 2000, 10000],
2971                    'exchanged': [1000, 2000, 10000],
2972                    'out': [25, 50, 731.40625],
2973                },
2974                3.75: {
2975                    'in': [200, 1000, 5000],
2976                    'exchanged': [750, 3750, 18750],
2977                    'out': [18.75, 93.75, 1371.38671875],
2978                },
2979            }.items():
2980                a, b, c = values['in']
2981                m, n, o = values['exchanged']
2982                x, y, z = values['out']
2983                if debug:
2984                    print('rate', rate, 'values', values)
2985                for case in [
2986                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2987                        {'safe': {0: {'below_nisab': x}}},
2988                    ], False, m),
2989                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2990                        {'safe': {0: {'count': 1, 'total': y}}},
2991                    ], True, n),
2992                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2993                        {'cave': {0: {'count': 3, 'total': z}}},
2994                    ], True, o),
2995                ]:
2996                    if debug:
2997                        print(f"############# check(rate: {rate}) #############")
2998                    self.reset()
2999                    self.exchange(account=case[1], created=case[2], rate=rate)
3000                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
3001
3002                    # assert self.nolock()
3003                    # history_size = len(self._vault['history'])
3004                    # print('history_size', history_size)
3005                    # assert history_size == 2
3006                    assert self.lock()
3007                    assert not self.nolock()
3008                    report = self.check(2.17, None, debug)
3009                    (valid, brief, plan) = report
3010                    assert valid == case[4]
3011                    if debug:
3012                        print('brief', brief)
3013                    assert case[5] == brief[0]
3014                    assert case[5] == brief[1]
3015
3016                    if debug:
3017                        pp().pprint(plan)
3018
3019                    for x in plan:
3020                        assert case[1] == x
3021                        if 'total' in case[3][0][x][0].keys():
3022                            assert case[3][0][x][0]['total'] == brief[2]
3023                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
3024                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
3025                        else:
3026                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3027                    if debug:
3028                        pp().pprint(report)
3029                    result = self.zakat(report, debug=debug)
3030                    if debug:
3031                        print('zakat-result', result, case[4])
3032                    assert result == case[4]
3033                    report = self.check(2.17, None, debug)
3034                    (valid, brief, plan) = report
3035                    assert valid is False
3036
3037            history_size = len(self._vault['history'])
3038            if debug:
3039                print('history_size', history_size)
3040            assert history_size == 3
3041            assert not self.nolock()
3042            assert self.recall(False, debug) is False
3043            self.free(self.lock())
3044            assert self.nolock()
3045
3046            for i in range(3, 0, -1):
3047                history_size = len(self._vault['history'])
3048                if debug:
3049                    print('history_size', history_size)
3050                assert history_size == i
3051                assert self.recall(False, debug) is True
3052
3053            assert self.nolock()
3054            assert self.recall(False, debug) is False
3055
3056            history_size = len(self._vault['history'])
3057            if debug:
3058                print('history_size', history_size)
3059            assert history_size == 0
3060
3061            account_size = len(self._vault['account'])
3062            if debug:
3063                print('account_size', account_size)
3064            assert account_size == 0
3065
3066            report_size = len(self._vault['report'])
3067            if debug:
3068                print('report_size', report_size)
3069            assert report_size == 0
3070
3071            assert self.nolock()
3072            return True
3073        except:
3074            # pp().pprint(self._vault)
3075            assert self.export_json("test-snapshot.json")
3076            assert self.save("test-snapshot.pickle")
3077            raise
def test(debug: bool = False):
3080def test(debug: bool = False):
3081    ledger = ZakatTracker()
3082    start = ZakatTracker.time()
3083    assert ledger.test(debug=debug)
3084    if debug:
3085        print("#########################")
3086        print("######## TEST DONE ########")
3087        print("#########################")
3088        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
3089        print("#########################")
class Action(enum.Enum):
76class Action(Enum):
77    CREATE = auto()
78    TRACK = auto()
79    LOG = auto()
80    SUB = auto()
81    ADD_FILE = auto()
82    REMOVE_FILE = auto()
83    BOX_TRANSFER = auto()
84    EXCHANGE = auto()
85    REPORT = auto()
86    ZAKAT = auto()
CREATE = <Action.CREATE: 1>
TRACK = <Action.TRACK: 2>
LOG = <Action.LOG: 3>
SUB = <Action.SUB: 4>
ADD_FILE = <Action.ADD_FILE: 5>
REMOVE_FILE = <Action.REMOVE_FILE: 6>
BOX_TRANSFER = <Action.BOX_TRANSFER: 7>
EXCHANGE = <Action.EXCHANGE: 8>
REPORT = <Action.REPORT: 9>
ZAKAT = <Action.ZAKAT: 10>
class JSONEncoder(json.encoder.JSONEncoder):
89class JSONEncoder(json.JSONEncoder):
90    def default(self, obj):
91        if isinstance(obj, Action) or isinstance(obj, MathOperation):
92            return obj.name  # Serialize as the enum member's name
93        elif isinstance(obj, Decimal):
94            return float(obj)
95        return super().default(obj)

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

Supports the following objects and types by default:

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

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

def default(self, obj):
90    def default(self, obj):
91        if isinstance(obj, Action) or isinstance(obj, MathOperation):
92            return obj.name  # Serialize as the enum member's name
93        elif isinstance(obj, Decimal):
94            return float(obj)
95        return super().default(obj)

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

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

def default(self, o):
    try:
        iterable = iter(o)
    except TypeError:
        pass
    else:
        return list(iterable)
    # Let the base class default method raise the TypeError
    return super().default(o)
class MathOperation(enum.Enum):
 98class MathOperation(Enum):
 99    ADDITION = auto()
100    EQUAL = auto()
101    SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>
def start_file_server( database_path: str, database_callback: <built-in function callable> = None, csv_callback: <built-in function callable> = None, debug: bool = False) -> tuple:
 55def start_file_server(database_path: str, database_callback: callable = None, csv_callback: callable = None,
 56                      debug: bool = False) -> tuple:
 57    """
 58    Starts a multi-purpose HTTP server to manage file interactions for a Zakat application.
 59
 60    This server facilitates the following functionalities:
 61
 62    1. GET /{file_uuid}/get: Download the database file specified by `database_path`.
 63    2. GET /{file_uuid}/upload: Display an HTML form for uploading files.
 64    3. POST /{file_uuid}/upload: Handle file uploads, distinguishing between:
 65        - Database File (.db): Replaces the existing database with the uploaded one.
 66        - CSV File (.csv): Imports data from the CSV into the existing database.
 67
 68    Args:
 69        database_path (str): The path to the pickle database file.
 70        database_callback (callable, optional): A function to call after a successful database upload.
 71                                                It receives the uploaded database path as its argument.
 72        csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path,
 73                                           the database path, and the debug flag as its arguments.
 74        debug (bool, optional): If True, print debugging information. Defaults to False.
 75
 76    Returns:
 77        Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
 78            - file_name (str): The name of the database file.
 79            - download_url (str): The URL to download the database file.
 80            - upload_url (str): The URL to access the file upload form.
 81            - server_thread (threading.Thread): The thread running the server.
 82            - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
 83
 84    Example:
 85        _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
 86        print(f"Download database: {download_url}")
 87        print(f"Upload files: {upload_url}")
 88        server_thread.start()
 89        # ... later ...
 90        shutdown_server()
 91    """
 92    file_uuid = uuid.uuid4()
 93    file_name = os.path.basename(database_path)
 94
 95    port = find_available_port()
 96    download_url = f"http://localhost:{port}/{file_uuid}/get"
 97    upload_url = f"http://localhost:{port}/{file_uuid}/upload"
 98
 99    class Handler(http.server.SimpleHTTPRequestHandler):
100        def do_GET(self):
101            if self.path == f"/{file_uuid}/get":
102                # GET: Serve the existing file
103                try:
104                    with open(database_path, "rb") as f:
105                        self.send_response(200)
106                        self.send_header("Content-type", "application/octet-stream")
107                        self.send_header("Content-Disposition", f'attachment; filename="{file_name}"')
108                        self.end_headers()
109                        self.wfile.write(f.read())
110                except FileNotFoundError:
111                    self.send_error(404, "File not found")
112            elif self.path == f"/{file_uuid}/upload":
113                # GET: Serve the upload form
114                self.send_response(200)
115                self.send_header("Content-type", "text/html")
116                self.end_headers()
117                self.wfile.write(f"""
118                    <html lang="en">
119                        <head>
120                            <title>Zakat File Server</title>
121                        </head>
122                    <body>
123                    <h1>Zakat File Server</h1>
124                    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
125                    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
126                    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
127                        <input type="file" name="file" required><br/>
128                        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
129                        <label for="database">Database File</label><br/>
130                        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
131                        <label for="csv">CSV File</label><br/>
132                        <input type="submit" value="Upload"><br/>
133                    </form>
134                    </body></html>
135                """.encode())
136            else:
137                self.send_error(404)
138
139        def do_POST(self):
140            if self.path == f"/{file_uuid}/upload":
141                # POST: Handle request
142                # 1. Get the Form Data
143                form_data = cgi.FieldStorage(
144                    fp=self.rfile,
145                    headers=self.headers,
146                    environ={'REQUEST_METHOD': 'POST'}
147                )
148                upload_type = form_data.getvalue("upload_type")
149
150                if debug:
151                    print('upload_type', upload_type)
152
153                if upload_type not in [FileType.Database.value, FileType.CSV.value]:
154                    self.send_error(400, "Invalid upload type")
155                    return
156
157                # 2. Extract File Data
158                file_item = form_data['file']  # Assuming 'file' is your file input name
159
160                # 3. Get File Details
161                filename = file_item.filename
162                file_data = file_item.file.read()  # Read the file's content
163
164                if debug:
165                    print(f'Uploaded filename: {filename}')
166
167                # 4. Define Storage Path for CSV
168                upload_directory = "./uploads"  # Create this directory if it doesn't exist
169                os.makedirs(upload_directory, exist_ok=True)
170                file_path = os.path.join(upload_directory, upload_type)
171
172                # 5. Write to Disk
173                with open(file_path, 'wb') as f:
174                    f.write(file_data)
175
176                match upload_type:
177                    case FileType.Database.value:
178
179                        try:
180                            # 6. Verify database file
181                            # ZakatTracker(db_path=file_path) # FATAL, Circular Imports Error
182                            if database_callback is not None:
183                                database_callback(file_path)
184
185                            # 7. Copy database into the original path
186                            shutil.copy2(file_path, database_path)
187                        except Exception as e:
188                            self.send_error(400, str(e))
189                            return
190
191                    case FileType.CSV.value:
192                        # 6. Verify CSV file
193                        try:
194                            # x = ZakatTracker(db_path=database_path) # FATAL, Circular Imports Error
195                            # result = x.import_csv(file_path, debug=debug)
196                            if csv_callback is not None:
197                                result = csv_callback(file_path, database_path, debug)
198                                if debug:
199                                    print(f'CSV imported: {result}')
200                                if len(result[2]) != 0:
201                                    self.send_response(200)
202                                    self.end_headers()
203                                    self.wfile.write(json.dumps(result).encode())
204                                    return
205                        except Exception as e:
206                            self.send_error(400, str(e))
207                            return
208
209                self.send_response(200)
210                self.end_headers()
211                self.wfile.write(b"File uploaded successfully.")
212
213    httpd = socketserver.TCPServer(("localhost", port), Handler)
214    server_thread = threading.Thread(target=httpd.serve_forever)
215
216    def shutdown_server():
217        nonlocal httpd, server_thread
218        httpd.shutdown()
219        httpd.server_close()  # Close the socket
220        server_thread.join()  # Wait for the thread to finish
221
222    return file_name, download_url, upload_url, server_thread, shutdown_server

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

This server facilitates the following functionalities:

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

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

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

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

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

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

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

Returns: int: The available TCP port number.

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

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

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