zakat
xxx

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

 _____     _         _     _     _ _                          
|__  /__ _| | ____ _| |_  | |   (_) |__  _ __ __ _ _ __ _   _ 
  / // _` | |/ / _` | __| | |   | | '_ \| '__/ _` | '__| | | |
 / /| (_| |   < (_| | |_  | |___| | |_) | | | (_| | |  | |_| |
/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_|  \__,_|_|   \__, |
... Never Trust, Always Verify ...                       |___/ 

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

 1"""
 2"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف
 3
 4```
 5 _____     _         _     _     _ _                          
 6|__  /__ _| | ____ _| |_  | |   (_) |__  _ __ __ _ _ __ _   _ 
 7  / // _` | |/ / _` | __| | |   | | '_ \| '__/ _` | '__| | | |
 8 / /| (_| |   < (_| | |_  | |___| | |_) | | | (_| | |  | |_| |
 9/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_|  \__,_|_|   \__, |
10... Never Trust, Always Verify ...                       |___/ 
11```
12
13This file provides the ZakatLibrary classes, functions for tracking and calculating Zakat.
14"""
15# Importing necessary classes and functions from the main module
16from zakat.zakat_tracker import (
17    ZakatTracker,
18    test,
19    Action,
20    JSONEncoder,
21    JSONDecoder,
22    MathOperation,
23    WeekDay,
24)
25
26from zakat.file_server import (
27    start_file_server,
28    find_available_port,
29    FileType,
30)
31
32# Version information for the module
33__version__ = ZakatTracker.Version()
34__all__ = [
35    "ZakatTracker",
36    "test",
37    "Action",
38    "JSONEncoder",
39    "JSONDecoder",
40    "MathOperation",
41    "WeekDay",
42    "start_file_server",
43    "find_available_port",
44    "FileType",
45]
class ZakatTracker:
 304class ZakatTracker:
 305    """
 306    A class for tracking and calculating Zakat.
 307
 308    This class provides functionalities for recording transactions, calculating Zakat due,
 309    and managing account balances. It also offers features like importing transactions from
 310    CSV files, exporting data to JSON format, and saving/loading the tracker state.
 311
 312    The `ZakatTracker` class is designed to handle both positive and negative transactions,
 313    allowing for flexible tracking of financial activities related to Zakat. It also supports
 314    the concept of a 'Nisab' (minimum threshold for Zakat) and a 'haul' (complete one year for Transaction) can calculate Zakat due
 315    based on the current silver price.
 316
 317    The class uses a camel file as its database to persist the tracker state,
 318    ensuring data integrity across sessions. It also provides options for enabling or
 319    disabling history tracking, allowing users to choose their preferred level of detail.
 320
 321    In addition, the `ZakatTracker` class includes various helper methods like
 322    `time`, `time_to_datetime`, `lock`, `free`, `recall`, `export_json`,
 323    and more. These methods provide additional functionalities and flexibility
 324    for interacting with and managing the Zakat tracker.
 325
 326    Attributes:
 327    - ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage.
 328    - ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat.
 329    - ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price.
 330    - ZakatTracker.Version (function): The version of the ZakatTracker class.
 331
 332    Data Structure:
 333    
 334    The ZakatTracker class utilizes a nested dictionary structure called '_vault' to store and manage data.
 335
 336        _vault (dict):
 337            - account (dict):
 338                - {account_name} (dict):
 339                    - balance (int): The current balance of the account.
 340                    - box (dict): A dictionary storing transaction details.
 341                        - {timestamp} (dict):
 342                            - capital (int): The initial amount of the transaction.
 343                            - count (int): The number of times Zakat has been calculated for this transaction.
 344                            - last (int): The timestamp of the last Zakat calculation.
 345                            - rest (int): The remaining amount after Zakat deductions and withdrawal.
 346                            - total (int): The total Zakat deducted from this transaction.
 347                    - count (int): The total number of transactions for the account.
 348                    - log (dict): A dictionary storing transaction logs.
 349                        - {timestamp} (dict):
 350                            - value (int): The transaction amount (positive or negative).
 351                            - desc (str): The description of the transaction.
 352                            - ref (int): The box reference (positive or None).
 353                            - file (dict): A dictionary storing file references associated with the transaction.
 354                    - hide (bool): Indicates whether the account is hidden or not.
 355                    - zakatable (bool): Indicates whether the account is subject to Zakat.
 356            - exchange (dict):
 357                - {account_name} (dict):
 358                    - {timestamps} (dict):
 359                        - rate (float): Exchange rate when compared to local currency.
 360                        - description (str): The description of the exchange rate.
 361            - history (dict):
 362                - {timestamp} (list): A list of dictionaries storing the history of actions performed.
 363                    - {action_dict} (dict):
 364                        - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
 365                        - account (str): The account number associated with the action.
 366                        - ref (int): The reference number of the transaction.
 367                        - file (int): The reference number of the file (if applicable).
 368                        - key (str): The key associated with the action (e.g., 'rest', 'total').
 369                        - value (int): The value associated with the action.
 370                        - math (MathOperation): The mathematical operation performed (if applicable).
 371            - lock (int or None): The timestamp indicating the current lock status (None if not locked).
 372            - report (dict):
 373                - {timestamp} (tuple): A tuple storing Zakat report details.
 374
 375    """
 376
 377    @staticmethod
 378    def Version() -> str:
 379        """
 380        Returns the current version of the software.
 381
 382        This function returns a string representing the current version of the software,
 383        including major, minor, and patch version numbers in the format 'X.Y.Z'.
 384
 385        Returns:
 386        - str: The current version of the software.
 387        """
 388        return '0.2.99'
 389
 390    @staticmethod
 391    def ZakatCut(x: float) -> float:
 392        """
 393        Calculates the Zakat amount due on an asset.
 394
 395        This function calculates the zakat amount due on a given asset value over one lunar year.
 396        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
 397        that exceeds a certain threshold (Nisab).
 398
 399        Parameters:
 400        - x (float): The total value of the asset on which Zakat is to be calculated.
 401
 402        Returns:
 403        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
 404        """
 405        return 0.025 * x  # Zakat Cut in one Lunar Year
 406
 407    @staticmethod
 408    def TimeCycle(days: int = 355) -> int:
 409        """
 410        Calculates the approximate duration of a lunar year in nanoseconds.
 411
 412        This function calculates the approximate duration of a lunar year based on the given number of days.
 413        It converts the given number of days into nanoseconds for use in high-precision timing applications.
 414
 415        Parameters:
 416        - days (int, optional): The number of days in a lunar year. Defaults to 355,
 417              which is an approximation of the average length of a lunar year.
 418
 419        Returns:
 420        - int: The approximate duration of a lunar year in nanoseconds.
 421        """
 422        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds
 423
 424    @staticmethod
 425    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
 426        """
 427        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
 428
 429        This function calculates the Nisab value, which is the minimum threshold of wealth,
 430        that makes an individual liable for paying Zakat.
 431        The Nisab value is determined by the equivalent value of a specific amount
 432        of gold or silver (currently 595 grams in silver) in the local currency.
 433
 434        Parameters:
 435        - gram_price (float): The price per gram of Nisab.
 436        - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
 437
 438        Returns:
 439        - float: The total value of Nisab based on the given price per gram.
 440        """
 441        return gram_price * gram_quantity
 442
 443    @staticmethod
 444    def ext() -> str:
 445        """
 446        Returns the file extension used by the ZakatTracker class.
 447
 448        Parameters:
 449        None
 450
 451        Returns:
 452        - str: The file extension used by the ZakatTracker class, which is 'camel'.
 453        """
 454        return 'camel'
 455
 456    _base_path = ""
 457    _vault_path = ""
 458    _memory_mode = False
 459    _vault: dict[str, dict[str, int | bool | dict[int, dict[str, int]] | None]]
 460
 461    def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True):
 462        """
 463        Initialize ZakatTracker with database path and history mode.
 464
 465        Parameters:
 466        - db_path (str, optional): The path to the database  directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
 467        - history_mode (bool, optional): The mode for tracking history. Default is True.
 468
 469        Returns:
 470        None
 471        """
 472        self.reset()
 473        self._memory_mode = db_path == ':memory:'
 474        self._history(history_mode)
 475        if not self._memory_mode:
 476            self.path(f'{db_path}/db.{self.ext()}')
 477
 478    def path(self, path: str = None) -> str:
 479        """
 480        Set or get the path to the database file.
 481
 482        If no path is provided, the current path is returned.
 483        If a path is provided, it is set as the new path.
 484        The function also creates the necessary directories if the provided path is a file.
 485
 486        Parameters:
 487        - path (str, optional): The new path to the database file. If not provided, the current path is returned.
 488
 489        Returns:
 490        - str: The current or new path to the database file.
 491        """
 492        if path is None:
 493            return self._vault_path
 494        self._vault_path = pathlib.Path(path).resolve()
 495        base_path = pathlib.Path(path).resolve()
 496        if base_path.is_file() or base_path.suffix:
 497            base_path = base_path.parent
 498        base_path.mkdir(parents=True, exist_ok=True)
 499        self._base_path = base_path
 500        return str(self._vault_path)
 501
 502    def base_path(self, *args) -> str:
 503        """
 504        Generate a base path by joining the provided arguments with the existing base path.
 505
 506        Parameters:
 507        - *args (str): Variable length argument list of strings to be joined with the base path.
 508
 509        Returns:
 510        - str: The generated base path. If no arguments are provided, the existing base path is returned.
 511        """
 512        if not args:
 513            return str(self._base_path)
 514        filtered_args = []
 515        ignored_filename = None
 516        for arg in args:
 517            if pathlib.Path(arg).suffix:
 518                ignored_filename = arg
 519            else:
 520                filtered_args.append(arg)
 521        base_path = pathlib.Path(self._base_path)
 522        full_path = base_path.joinpath(*filtered_args)
 523        full_path.mkdir(parents=True, exist_ok=True)
 524        if ignored_filename is not None:
 525            return full_path.resolve() / ignored_filename  # Join with the ignored filename
 526        return str(full_path.resolve())
 527
 528    @staticmethod
 529    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
 530        """
 531        Scales a numerical value by a specified power of 10, returning an integer.
 532
 533        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
 534        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
 535
 536        Parameters:
 537        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
 538        - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
 539            by a factor of 100 (e.g., converts 1.23 to 123).
 540
 541        Returns:
 542        - The scaled value, rounded to the nearest integer.
 543
 544        Raises:
 545        - TypeError: If the input `x` is not a valid numeric type.
 546
 547        Examples:
 548        >>> ZakatTracker.scale(3.14159)
 549        314
 550        >>> ZakatTracker.scale(1234, decimal_places=3)
 551        1234000
 552        >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
 553        50
 554        """
 555        if not isinstance(x, (float, int, decimal.Decimal)):
 556            raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.')
 557        return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places))
 558
 559    @staticmethod
 560    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
 561        """
 562        Unscales an integer by a power of 10.
 563
 564        Parameters:
 565        - x (int): The integer to unscale.
 566        - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
 567        - decimal_places (int, optional): The power of 10 to use. Defaults to 2.
 568
 569        Returns:
 570        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
 571
 572        Raises:
 573        - TypeError: If the return_type is not float or decimal.Decimal.
 574        """
 575        if return_type not in (float, decimal.Decimal):
 576            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
 577        return round(return_type(x / (10 ** decimal_places)), decimal_places)
 578
 579    def reset(self) -> None:
 580        """
 581        Reset the internal data structure to its initial state.
 582
 583        Parameters:
 584        None
 585
 586        Returns:
 587        None
 588        """
 589        self._vault = {
 590            'account': {},
 591            'exchange': {},
 592            'history': {},
 593            'lock': None,
 594            'report': {},
 595        }
 596
 597    _last_time_ns = None
 598    _time_diff_ns = None
 599
 600    @staticmethod
 601    def minimum_time_diff_ns() -> tuple[int, int]:
 602        """
 603        Calculates the minimum time difference between two consecutive calls to
 604        `ZakatTracker._time()` in nanoseconds.
 605
 606        This method is used internally to determine the minimum granularity of
 607        time measurements within the system.
 608
 609        Returns:
 610        - tuple[int, int]:
 611            - The minimum time difference in nanoseconds.
 612            - The number of iterations required to measure the difference.
 613        """
 614        i = 0
 615        x = y = ZakatTracker._time()
 616        while x == y:
 617            y = ZakatTracker._time()
 618            i += 1
 619        return y - x, i
 620
 621    @staticmethod
 622    def _time(now: datetime.datetime = None) -> int:
 623        """
 624        Internal method to generate a nanosecond-precision timestamp from a datetime object.
 625
 626        Parameters:
 627        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
 628        If not provided, the current datetime is used.
 629
 630        Returns:
 631        - int: The timestamp in nanoseconds since the epoch (January 1, 1AD).
 632        """
 633        if now is None:
 634            now = datetime.datetime.now()
 635        ns_in_day = (now - now.replace(
 636        	hour=0,
 637        	minute=0,
 638        	second=0,
 639        	microsecond=0,
 640        )).total_seconds() * 10 ** 9
 641        return int(now.toordinal() * 86_400_000_000_000 + ns_in_day)
 642
 643    @staticmethod
 644    def time(now: datetime.datetime = None) -> int:
 645        """
 646        Generates a unique, monotonically increasing timestamp based on the provided
 647        datetime object or the current datetime.
 648
 649        This method ensures that timestamps are unique even if called in rapid succession
 650        by introducing a small delay if necessary, based on the system's minimum
 651        time resolution.
 652
 653        Parameters:
 654        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
 655        If not provided, the current datetime is used.
 656
 657        Returns:
 658        - int: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
 659        """
 660        new_time = ZakatTracker._time(now)
 661        if ZakatTracker._last_time_ns is None:
 662            ZakatTracker._last_time_ns = new_time
 663            return new_time
 664        while new_time == ZakatTracker._last_time_ns:
 665            if ZakatTracker._time_diff_ns is None:
 666                diff, _ = ZakatTracker.minimum_time_diff_ns()
 667                ZakatTracker._time_diff_ns = math.ceil(diff)
 668            time.sleep(ZakatTracker._time_diff_ns / 1_000_000_000)
 669            new_time = ZakatTracker._time()
 670        ZakatTracker._last_time_ns = new_time
 671        return new_time
 672
 673    @staticmethod
 674    def time_to_datetime(ordinal_ns: int) -> datetime.datetime:
 675        """
 676        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
 677        back to a datetime object.
 678
 679        Parameters:
 680        - ordinal_ns (int): The timestamp in nanoseconds since the epoch (January 1, 1AD).
 681
 682        Returns:
 683        - datetime.datetime: The corresponding datetime object.
 684        """
 685        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
 686        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
 687        return datetime.datetime.combine(d, datetime.time()) + t
 688
 689    def clean_history(self, lock: int = None) -> int:
 690        """
 691        Cleans up the empty history records of actions performed on the ZakatTracker instance.
 692
 693        Parameters:
 694        - lock (int, optional): The lock ID is used to clean up the empty history.
 695            If not provided, it cleans up the empty history records for all locks.
 696
 697        Returns:
 698        - int: The number of locks cleaned up.
 699        """
 700        count = 0
 701        if lock in self._vault['history']:
 702            if len(self._vault['history'][lock]) <= 0:
 703                count += 1
 704                del self._vault['history'][lock]
 705            return count
 706        self.free(self.lock())
 707        for key in self._vault['history']:
 708            if len(self._vault['history'][key]) <= 0:
 709                count += 1
 710                del self._vault['history'][key]
 711        return count
 712
 713    def _history(self, status: bool = None) -> bool:
 714        """
 715        Enable or disable history tracking.
 716
 717        Parameters:
 718        - status (bool, optional): The status of history tracking. Default is True.
 719
 720        Returns:
 721        None
 722        """
 723        if status is not None:
 724            self._history_mode = status
 725        return self._history_mode
 726
 727    def _step(self, action: Action = None,
 728                    account: str = None,
 729                    ref: int = None,
 730                    file: int = None,
 731                    value: float = None,
 732                    key: str = None,
 733                    math_operation: MathOperation = None,
 734                    lock_once: bool = True,
 735                    debug: bool = False,
 736                ) -> int:
 737        """
 738        This method is responsible for recording the actions performed on the ZakatTracker.
 739
 740        Parameters:
 741        - action (Action, optional): The type of action performed.
 742        - account (str, optional): The account number on which the action was performed.
 743        - ref (int, optional): The reference number of the action.
 744        - file (int, optional): The file reference number of the action.
 745        - value (int, optional): The value associated with the action.
 746        - key (str, optional): The key associated with the action.
 747        - math_operation (MathOperation, optional): The mathematical operation performed during the action.
 748        - lock_once (bool, optional): Indicates whether a lock should be acquired only once. Defaults to True.
 749        - debug (bool, optional): If True, the function will print debug information. Default is False.
 750
 751        Returns:
 752        - int: The lock time of the recorded action. If no lock was performed, it returns 0.
 753        """
 754        if not self._history():
 755            return 0
 756        no_lock = self.nolock()
 757        lock = self._vault['lock']
 758        if no_lock:
 759            lock = self._vault['lock'] = self.time()
 760            self._vault['history'][lock] = []
 761        if action is None:
 762            if lock_once:
 763                assert no_lock, 'forbidden: lock called twice!!!'
 764            return lock
 765        if debug:
 766             print_stack()
 767        assert lock > 0
 768        assert account is None or action != Action.REPORT
 769        self._vault['history'][lock].append({
 770            'action': action,
 771            'account': account,
 772            'ref': ref,
 773            'file': file,
 774            'key': key,
 775            'value': value,
 776            'math': math_operation,
 777        })
 778        return lock
 779
 780    def nolock(self) -> bool:
 781        """
 782        Check if the vault lock is currently not set.
 783
 784        Parameters:
 785        None
 786
 787        Returns:
 788        - bool: True if the vault lock is not set, False otherwise.
 789        """
 790        return self._vault['lock'] is None
 791
 792    def _lock(self) -> int:
 793        """
 794        Acquires a lock, potentially repeatedly, by calling the internal `_step` method.
 795
 796        This method specifically invokes the `_step` method with `lock_once` set to `False`
 797        indicating that the lock should be acquired even if it was previously acquired.
 798        This is useful for ensuring a lock is held throughout a critical section of code
 799
 800        Returns:
 801        - int: The status code or result returned by the `_step` method, indicating theoutcome of the lock acquisition attempt.
 802        """
 803        return self._step(lock_once=False)
 804
 805    def lock(self) -> int:
 806        """
 807        Acquires a lock on the ZakatTracker instance.
 808
 809        Parameters:
 810        None
 811
 812        Returns:
 813        - int: The lock ID. This ID can be used to release the lock later.
 814        """
 815        return self._step()
 816
 817    def steps(self) -> dict:
 818        """
 819        Returns a copy of the history of steps taken in the ZakatTracker.
 820
 821        The history is a dictionary where each key is a unique identifier for a step,
 822        and the corresponding value is a dictionary containing information about the step.
 823
 824        Parameters:
 825        None
 826
 827        Returns:
 828        - dict: A copy of the history of steps taken in the ZakatTracker.
 829        """
 830        return self._vault['history'].copy()
 831
 832    def free(self, lock: int, auto_save: bool = True) -> bool:
 833        """
 834        Releases the lock on the database.
 835
 836        Parameters:
 837        - lock (int): The lock ID to be released.
 838        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
 839
 840        Returns:
 841        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
 842        """
 843        if lock == self._vault['lock']:
 844            self.clean_history(lock)
 845            self._vault['lock'] = None
 846            if auto_save and not self._memory_mode:
 847                return self.save(self.path())
 848            return True
 849        return False
 850
 851    def recall(self, dry: bool = True, lock: int = None, debug: bool = False) -> bool:
 852        """
 853        Revert the last operation.
 854
 855        Parameters:
 856        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
 857        - lock (int, optional): An optional lock value to ensure the recall
 858                operation is performed on the expected history entry. If provided,
 859                it checks if the current lock and the most recent history key
 860                match the given lock value. Defaults to None.
 861        - debug (bool, optional): If True, the function will print debug information. Default is False.
 862
 863        Returns:
 864        - bool: True if the operation was successful, False otherwise.
 865        """
 866        if not self.nolock() or len(self._vault['history']) == 0:
 867            return False
 868        if len(self._vault['history']) <= 0:
 869            return False
 870        ref = sorted(self._vault['history'].keys())[-1]
 871        if debug:
 872            print('recall', ref)
 873        memory = self._vault['history'][ref]
 874        if debug:
 875            print(type(memory), 'memory', memory)
 876        if lock is not None:
 877            assert self._vault['lock'] == lock, "Invalid current lock"
 878            assert ref == lock, "Invalid last lock"
 879            assert self._history(), "History mode should be enabled, found off!!!"
 880        limit = len(memory) + 1
 881        sub_positive_log_negative = 0
 882        for i in range(-1, -limit, -1):
 883            x = memory[i]
 884            if debug:
 885                print(type(x), x)
 886            match x['action']:
 887                case Action.CREATE:
 888                    if x['account'] is not None:
 889                        if self.account_exists(x['account']):
 890                            if debug:
 891                                print('account', self._vault['account'][x['account']])
 892                            assert len(self._vault['account'][x['account']]['box']) == 0
 893                            assert len(self._vault['account'][x['account']]['log']) == 0
 894                            assert self._vault['account'][x['account']]['balance'] == 0
 895                            assert self._vault['account'][x['account']]['count'] == 0
 896                            if dry:
 897                                continue
 898                            del self._vault['account'][x['account']]
 899
 900                case Action.TRACK:
 901                    if x['account'] is not None:
 902                        if self.account_exists(x['account']):
 903                            if dry:
 904                                continue
 905                            self._vault['account'][x['account']]['balance'] -= x['value']
 906                            self._vault['account'][x['account']]['count'] -= 1
 907                            del self._vault['account'][x['account']]['box'][x['ref']]
 908
 909                case Action.LOG:
 910                    if x['account'] is not None:
 911                        if self.account_exists(x['account']):
 912                            if x['ref'] in self._vault['account'][x['account']]['log']:
 913                                if dry:
 914                                    continue
 915                                if sub_positive_log_negative == -x['value']:
 916                                    self._vault['account'][x['account']]['count'] -= 1
 917                                    sub_positive_log_negative = 0
 918                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
 919                                if not box_ref is None:
 920                                    assert self.box_exists(x['account'], box_ref)
 921                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
 922                                    assert box_value < 0
 923
 924                                    try:
 925                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
 926                                    except TypeError:
 927                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += decimal.Decimal(
 928                                            -box_value)
 929
 930                                    try:
 931                                        self._vault['account'][x['account']]['balance'] += -box_value
 932                                    except TypeError:
 933                                        self._vault['account'][x['account']]['balance'] += decimal.Decimal(-box_value)
 934
 935                                    self._vault['account'][x['account']]['count'] -= 1
 936                                del self._vault['account'][x['account']]['log'][x['ref']]
 937
 938                case Action.SUB:
 939                    if x['account'] is not None:
 940                        if self.account_exists(x['account']):
 941                            if x['ref'] in self._vault['account'][x['account']]['box']:
 942                                if dry:
 943                                    continue
 944                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
 945                                self._vault['account'][x['account']]['balance'] += x['value']
 946                                sub_positive_log_negative = x['value']
 947
 948                case Action.ADD_FILE:
 949                    if x['account'] is not None:
 950                        if self.account_exists(x['account']):
 951                            if x['ref'] in self._vault['account'][x['account']]['log']:
 952                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
 953                                    if dry:
 954                                        continue
 955                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
 956
 957                case Action.REMOVE_FILE:
 958                    if x['account'] is not None:
 959                        if self.account_exists(x['account']):
 960                            if x['ref'] in self._vault['account'][x['account']]['log']:
 961                                if dry:
 962                                    continue
 963                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
 964
 965                case Action.BOX_TRANSFER:
 966                    if x['account'] is not None:
 967                        if self.account_exists(x['account']):
 968                            if x['ref'] in self._vault['account'][x['account']]['box']:
 969                                if dry:
 970                                    continue
 971                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
 972
 973                case Action.EXCHANGE:
 974                    if x['account'] is not None:
 975                        if x['account'] in self._vault['exchange']:
 976                            if x['ref'] in self._vault['exchange'][x['account']]:
 977                                if dry:
 978                                    continue
 979                                del self._vault['exchange'][x['account']][x['ref']]
 980
 981                case Action.REPORT:
 982                    if x['ref'] in self._vault['report']:
 983                        if dry:
 984                            continue
 985                        del self._vault['report'][x['ref']]
 986
 987                case Action.ZAKAT:
 988                    if x['account'] is not None:
 989                        if self.account_exists(x['account']):
 990                            if x['ref'] in self._vault['account'][x['account']]['box']:
 991                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
 992                                    if dry:
 993                                        continue
 994                                    match x['math']:
 995                                        case MathOperation.ADDITION:
 996                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
 997                                                'value']
 998                                        case MathOperation.EQUAL:
 999                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
1000                                        case MathOperation.SUBTRACTION:
1001                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
1002                                                'value']
1003
1004        if not dry:
1005            del self._vault['history'][ref]
1006        return True
1007
1008    def vault(self) -> dict:
1009        """
1010        Returns a copy of the internal vault dictionary.
1011
1012        This method is used to retrieve the current state of the ZakatTracker object.
1013        It provides a snapshot of the internal data structure, allowing for further
1014        processing or analysis.
1015
1016        Parameters:
1017        None
1018
1019        Returns:
1020        - dict: A copy of the internal vault dictionary.
1021        """
1022        return self._vault.copy()
1023
1024    @staticmethod
1025    def stats_init() -> dict[str, tuple[int, str]]:
1026        """
1027        Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.
1028
1029        The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:
1030        - The initial size of the respective statistic in bytes (int).
1031        - The initial size of the respective statistic in a human-readable format (str).
1032
1033        Parameters:
1034        None
1035
1036        Returns:
1037        - dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
1038        """
1039        return {
1040            'database': (0, '0'),
1041            'ram': (0, '0'),
1042        }
1043
1044    def stats(self, ignore_ram: bool = True) -> dict[str, tuple[float, str]]:
1045        """
1046        Calculates and returns statistics about the object's data storage.
1047
1048        This method determines the size of the database file on disk and the
1049        size of the data currently held in RAM (likely within a dictionary).
1050        Both sizes are reported in bytes and in a human-readable format
1051        (e.g., KB, MB).
1052
1053        Parameters:
1054        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
1055
1056        Returns:
1057        - dict[str, tuple[float, str]]: A dictionary containing the following statistics:
1058
1059            * 'database': A tuple with two elements:
1060                - The database file size in bytes (float).
1061                - The database file size in human-readable format (str).
1062            * 'ram': A tuple with two elements:
1063                - The RAM usage (dictionary size) in bytes (float).
1064                - The RAM usage in human-readable format (str).
1065
1066        Example:
1067        >>> x = ZakatTracker()
1068        >>> stats = x.stats()
1069        >>> print(stats['database'])
1070        (256000, '250.0 KB')
1071        >>> print(stats['ram'])
1072        (12345, '12.1 KB')
1073        """
1074        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
1075        file_size = os.path.getsize(self.path())
1076        return {
1077            'database': (file_size, self.human_readable_size(file_size)),
1078            'ram': (ram_size, self.human_readable_size(ram_size)),
1079        }
1080
1081    def files(self) -> list[dict[str, str | int]]:
1082        """
1083        Retrieves information about files associated with this class.
1084
1085        This class method provides a standardized way to gather details about
1086        files used by the class for storage, snapshots, and CSV imports.
1087
1088        Parameters:
1089        None
1090
1091        Returns:
1092        - list[dict[str, str | int]]: A list of dictionaries, each containing information
1093            about a specific file:
1094
1095            * type (str): The type of file ('database', 'snapshot', 'import_csv').
1096            * path (str): The full file path.
1097            * exists (bool): Whether the file exists on the filesystem.
1098            * size (int): The file size in bytes (0 if the file doesn't exist).
1099            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
1100
1101        Example:
1102        ```
1103        file_info = MyClass.files()
1104        for info in file_info:
1105            print(f'Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}')
1106        ```
1107        """
1108        result = []
1109        for file_type, path in {
1110            'database': self.path(),
1111            'snapshot': self.snapshot_cache_path(),
1112            'import_csv': self.import_csv_cache_path(),
1113        }.items():
1114            exists = os.path.exists(path)
1115            size = os.path.getsize(path) if exists else 0
1116            human_readable_size = self.human_readable_size(size) if exists else 0
1117            result.append({
1118                'type': file_type,
1119                'path': path,
1120                'exists': exists,
1121                'size': size,
1122                'human_readable_size': human_readable_size,
1123            })
1124        return result
1125
1126    def account_exists(self, account) -> bool:
1127        """
1128        Check if the given account exists in the vault.
1129
1130        Parameters:
1131        - account (str): The account number to check.
1132
1133        Returns:
1134        - bool: True if the account exists, False otherwise.
1135        """
1136        return account in self._vault['account']
1137
1138    def box_size(self, account) -> int:
1139        """
1140        Calculate the size of the box for a specific account.
1141
1142        Parameters:
1143        - account (str): The account number for which the box size needs to be calculated.
1144
1145        Returns:
1146        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
1147        """
1148        if self.account_exists(account):
1149            return len(self._vault['account'][account]['box'])
1150        return -1
1151
1152    def log_size(self, account) -> int:
1153        """
1154        Get the size of the log for a specific account.
1155
1156        Parameters:
1157        - account (str): The account number for which the log size needs to be calculated.
1158
1159        Returns:
1160        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
1161        """
1162        if self.account_exists(account):
1163            return len(self._vault['account'][account]['log'])
1164        return -1
1165
1166    @staticmethod
1167    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
1168        """
1169        Calculates the hash of given byte data using the specified algorithm.
1170
1171        Parameters:
1172        - data (bytes): The byte data to hash.
1173        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
1174
1175        Returns:
1176        - str: The hexadecimal representation of the data's hash.
1177        """
1178        hash_obj = hashlib.new(algorithm)
1179        hash_obj.update(data)
1180        return hash_obj.hexdigest()
1181
1182    @staticmethod
1183    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
1184        """
1185        Calculates the hash of a file using the specified algorithm.
1186
1187        Parameters:
1188        - file_path (str): The path to the file.
1189        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
1190
1191        Returns:
1192        - str: The hexadecimal representation of the file's hash.
1193        """
1194        hash_obj = hashlib.new(algorithm)  # Create the hash object
1195        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
1196            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
1197                hash_obj.update(chunk)
1198        return hash_obj.hexdigest()  # Return the hash as a hexadecimal string
1199
1200    def snapshot_cache_path(self):
1201        """
1202        Generate the path for the cache file used to store snapshots.
1203
1204        The cache file is a camel file that stores the timestamps of the snapshots.
1205        The file name is derived from the main database file name by replacing the '.camel' extension with '.snapshots.camel'.
1206
1207        Parameters:
1208        None
1209
1210        Returns:
1211        - str: The path to the cache file.
1212        """
1213        path = str(self.path())
1214        ext = self.ext()
1215        ext_len = len(ext)
1216        if path.endswith(f'.{ext}'):
1217            path = path[:-ext_len - 1]
1218        _, filename = os.path.split(path + f'.snapshots.{ext}')
1219        return self.base_path(filename)
1220
1221    def snapshot(self) -> bool:
1222        """
1223        This function creates a snapshot of the current database state.
1224
1225        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
1226        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
1227        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
1228        in a new camel 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.
1229
1230        Parameters:
1231        None
1232
1233        Returns:
1234        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
1235        """
1236        current_hash = self.hash_file(self.path())
1237        cache: dict[str, int] = {}  # hash: time_ns
1238        try:
1239            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
1240                cache = camel.load(stream.read())
1241        except:
1242            pass
1243        if current_hash in cache:
1244            return True
1245        ref = time.time_ns()
1246        cache[current_hash] = ref
1247        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
1248            return False
1249        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
1250            stream.write(camel.dump(cache))
1251        return True
1252
1253    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
1254            -> dict[int, tuple[str, str, bool]]:
1255        """
1256        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
1257
1258        Parameters:
1259        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
1260        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
1261
1262        Returns:
1263        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
1264        and the values are tuples containing the snapshot's hash, path, and existence status.
1265        """
1266        cache: dict[str, int] = {}  # hash: time_ns
1267        try:
1268            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
1269                cache = camel.load(stream.read())
1270        except:
1271            pass
1272        if not cache:
1273            return {}
1274        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
1275        for hash_file, ref in cache.items():
1276            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
1277            exists = os.path.exists(path)
1278            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
1279            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
1280                continue
1281            if exists or not hide_missing:
1282                result[ref] = (hash_file, path, exists)
1283        return result
1284
1285    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
1286        """
1287        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
1288
1289        Parameters:
1290        - account (str): The account number for which to check the existence of the reference.
1291        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
1292        - ref (int): The reference (transaction) number to check for existence.
1293
1294        Returns:
1295        - bool: True if the reference exists for the given account and reference type, False otherwise.
1296        """
1297        if account in self._vault['account']:
1298            return ref in self._vault['account'][account][ref_type]
1299        return False
1300
1301    def box_exists(self, account: str, ref: int) -> bool:
1302        """
1303        Check if a specific box (transaction) exists in the vault for a given account and reference.
1304
1305        Parameters:
1306        - account (str): The account number for which to check the existence of the box.
1307        - ref (int): The reference (transaction) number to check for existence.
1308
1309        Returns:
1310        - bool: True if the box exists for the given account and reference, False otherwise.
1311        """
1312        return self.ref_exists(account, 'box', ref)
1313
1314    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: str = 1,
1315              logging: bool = True,
1316              created_time_ns: int = None,
1317              debug: bool = False) -> int:
1318        """
1319        This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
1320
1321        Parameters:
1322        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
1323        - desc (str, optional): The description of the transaction. Default is an empty string.
1324        - account (str, optional): The account for which the transaction is being tracked. Default is '1'.
1325        - logging (bool, optional): Whether to log the transaction. Default is True.
1326        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
1327        - debug (bool, optional): Whether to print debug information. Default is False.
1328
1329        Returns:
1330        - int: The timestamp of the transaction in nanoseconds since epoch(1AD).
1331
1332        Raises:
1333        - ValueError: The created_time_ns should be greater than zero.
1334        - ValueError: The log transaction happened again in the same nanosecond time.
1335        - ValueError: The box transaction happened again in the same nanosecond time.
1336        """
1337        if debug:
1338            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
1339        if created_time_ns is None:
1340            created_time_ns = self.time()
1341        if created_time_ns <= 0:
1342            raise ValueError('The created should be greater than zero.')
1343        no_lock = self.nolock()
1344        lock = self._lock()
1345        if not self.account_exists(account):
1346            if debug:
1347                print(f'account {account} created')
1348            self._vault['account'][account] = {
1349                'balance': 0,
1350                'box': {},
1351                'count': 0,
1352                'log': {},
1353                'hide': False,
1354                'zakatable': True,
1355                'created': created_time_ns,
1356            }
1357            self._step(Action.CREATE, account)
1358        if unscaled_value == 0:
1359            if no_lock:
1360                self.free(lock)
1361            return 0
1362        value = self.scale(unscaled_value)
1363        if logging:
1364            self._log(value=value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
1365        if debug:
1366            print('create-box', created_time_ns)
1367        if self.box_exists(account, created_time_ns):
1368            raise ValueError(f'The box transaction happened again in the same nanosecond time({created_time_ns}).')
1369        if debug:
1370            print('created-box', created_time_ns)
1371        self._vault['account'][account]['box'][created_time_ns] = {
1372            'capital': value,
1373            'count': 0,
1374            'last': 0,
1375            'rest': value,
1376            'total': 0,
1377        }
1378        self._step(Action.TRACK, account, ref=created_time_ns, value=value)
1379        if no_lock:
1380            self.free(lock)
1381        return created_time_ns
1382
1383    def log_exists(self, account: str, ref: int) -> bool:
1384        """
1385        Checks if a specific transaction log entry exists for a given account.
1386
1387        Parameters:
1388        - account (str): The account number associated with the transaction log.
1389        - ref (int): The reference to the transaction log entry.
1390
1391        Returns:
1392        - bool: True if the transaction log entry exists, False otherwise.
1393        """
1394        return self.ref_exists(account, 'log', ref)
1395
1396    def _log(self, value: float, desc: str = '', account: str = 1, created_time_ns: int = None, ref: int = None,
1397             debug: bool = False) -> int:
1398        """
1399        Log a transaction into the account's log by updates the account's balance, count, and log with the transaction details.
1400        It also creates a step in the history of the transaction.
1401
1402        Parameters:
1403        - value (float): The value of the transaction.
1404        - desc (str, optional): The description of the transaction.
1405        - account (str, optional): The account to log the transaction into. Default is '1'.
1406        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
1407                                           If not provided, it will be generated.
1408        - ref (int, optional): The reference of the object.
1409        - debug (bool, optional): Whether to print debug information. Default is False.
1410
1411        Returns:
1412        - int: The timestamp of the logged transaction.
1413
1414        Raises:
1415        - ValueError: The created_time_ns should be greater than zero.
1416        - ValueError: The log transaction happened again in the same nanosecond time.
1417        """
1418        if debug:
1419            print('_log', f'debug={debug}')
1420        if created_time_ns is None:
1421            created_time_ns = self.time()
1422        if created_time_ns <= 0:
1423            raise ValueError('The created should be greater than zero.')
1424        try:
1425            self._vault['account'][account]['balance'] += value
1426        except TypeError:
1427            self._vault['account'][account]['balance'] += decimal.Decimal(value)
1428        self._vault['account'][account]['count'] += 1
1429        if debug:
1430            print('create-log', created_time_ns)
1431        if self.log_exists(account, created_time_ns):
1432            raise ValueError(f'The log transaction happened again in the same nanosecond time({created_time_ns}).')
1433        if debug:
1434            print('created-log', created_time_ns)
1435        self._vault['account'][account]['log'][created_time_ns] = {
1436            'value': value,
1437            'desc': desc,
1438            'ref': ref,
1439            'file': {},
1440        }
1441        self._step(Action.LOG, account, ref=created_time_ns, value=value)
1442        return created_time_ns
1443
1444    def exchange(self, account, created_time_ns: int = None, rate: float = None, description: str = None,
1445                 debug: bool = False) -> dict:
1446        """
1447        This method is used to record or retrieve exchange rates for a specific account.
1448
1449        Parameters:
1450        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1451        - created_time_ns (int, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1452        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1453        - description (str, optional): A description of the exchange rate.
1454        - debug (bool, optional): Whether to print debug information. Default is False.
1455
1456        Returns:
1457        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1458        it returns a dictionary with default values for the rate and description.
1459
1460        Raises:
1461        - ValueError: The created should be greater than zero.
1462        """
1463        if debug:
1464            print('exchange', f'debug={debug}')
1465        if created_time_ns is None:
1466            created_time_ns = self.time()
1467        if created_time_ns <= 0:
1468            raise ValueError('The created should be greater than zero.')
1469        if rate is not None:
1470            if rate <= 0:
1471                return {}
1472            if account not in self._vault['exchange']:
1473                self._vault['exchange'][account] = {}
1474            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1475                return {'time': created_time_ns, 'rate': 1, 'description': None}
1476            no_lock = self.nolock()
1477            lock = self._lock()
1478            self._vault['exchange'][account][created_time_ns] = {'rate': rate, 'description': description}
1479            self._step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
1480            if no_lock:
1481                self.free(lock)
1482            if debug:
1483                print('exchange-created-1',
1484                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
1485
1486        if account in self._vault['exchange']:
1487            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created_time_ns]
1488            if valid_rates:
1489                latest_rate = max(valid_rates, key=lambda x: x[0])
1490                if debug:
1491                    print('exchange-read-1',
1492                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
1493                          'latest_rate', latest_rate)
1494                result = latest_rate[1]
1495                result['time'] = latest_rate[0]
1496                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1497        if debug:
1498            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
1499        return {'time': created_time_ns, 'rate': 1, 'description': None}  # إرجاع القيمة الافتراضية مع وصف فارغ
1500
1501    @staticmethod
1502    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1503        """
1504        This function calculates the exchanged amount of a currency.
1505
1506        Parameters:
1507        - x (float): The original amount of the currency.
1508        - x_rate (float): The exchange rate of the original currency.
1509        - y_rate (float): The exchange rate of the target currency.
1510
1511        Returns:
1512        - float: The exchanged amount of the target currency.
1513        """
1514        return (x * x_rate) / y_rate
1515
1516    def exchanges(self) -> dict:
1517        """
1518        Retrieve the recorded exchange rates for all accounts.
1519
1520        Parameters:
1521        None
1522
1523        Returns:
1524        - dict: A dictionary containing all recorded exchange rates.
1525        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1526        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1527        """
1528        return self._vault['exchange'].copy()
1529
1530    def accounts(self) -> dict:
1531        """
1532        Returns a dictionary containing account numbers as keys and their respective balances as values.
1533
1534        Parameters:
1535        None
1536
1537        Returns:
1538        - dict: A dictionary where keys are account numbers and values are their respective balances.
1539        """
1540        result = {}
1541        for i in self._vault['account']:
1542            result[i] = self._vault['account'][i]['balance']
1543        return result
1544
1545    def boxes(self, account) -> dict:
1546        """
1547        Retrieve the boxes (transactions) associated with a specific account.
1548
1549        Parameters:
1550        - account (str): The account number for which to retrieve the boxes.
1551
1552        Returns:
1553        - dict: A dictionary containing the boxes associated with the given account.
1554        If the account does not exist, an empty dictionary is returned.
1555        """
1556        if self.account_exists(account):
1557            return self._vault['account'][account]['box']
1558        return {}
1559
1560    def logs(self, account) -> dict:
1561        """
1562        Retrieve the logs (transactions) associated with a specific account.
1563
1564        Parameters:
1565        - account (str): The account number for which to retrieve the logs.
1566
1567        Returns:
1568        - dict: A dictionary containing the logs associated with the given account.
1569        If the account does not exist, an empty dictionary is returned.
1570        """
1571        if self.account_exists(account):
1572            return self._vault['account'][account]['log']
1573        return {}
1574
1575    @staticmethod
1576    def daily_logs_init() -> dict[str, dict]:
1577        """
1578        Initialize a dictionary to store daily, weekly, monthly, and yearly logs.
1579
1580        Parameters:
1581        None
1582
1583        Returns:
1584        - dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary.
1585            Later each key maps to another dictionary, which will store the logs for the corresponding time period.
1586        """
1587        return {
1588            'daily': {},
1589            'weekly': {},
1590            'monthly': {},
1591            'yearly': {},
1592        }
1593
1594    def daily_logs(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False):
1595        """
1596        Retrieve the daily logs (transactions) from all accounts.
1597
1598        The function groups the logs by day, month, and year, and calculates the total value for each group.
1599        It returns a dictionary where the keys are the timestamps of the daily groups,
1600        and the values are dictionaries containing the total value and the logs for that group.
1601
1602        Parameters:
1603        - weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday.
1604        - debug (bool, optional): Whether to print debug information. Default is False.
1605
1606        Returns:
1607        - dict: A dictionary containing the daily logs.
1608
1609        Example:
1610        >>> tracker = ZakatTracker()
1611        >>> tracker.sub(51, 'desc', 'account1')
1612        >>> ref = tracker.track(100, 'desc', 'account2')
1613        >>> tracker.add_file('account2', ref, 'file_0')
1614        >>> tracker.add_file('account2', ref, 'file_1')
1615        >>> tracker.add_file('account2', ref, 'file_2')
1616        >>> tracker.daily_logs()
1617        {
1618            'daily': {
1619                '2024-06-30': {
1620                    'positive': 100,
1621                    'negative': 51,
1622                    'total': 99,
1623                    'rows': [
1624                        {
1625                            'account': 'account1',
1626                            'desc': 'desc',
1627                            'file': {},
1628                            'ref': None,
1629                            'value': -51,
1630                            'time': 1690977015000000000,
1631                            'transfer': False,
1632                        },
1633                        {
1634                            'account': 'account2',
1635                            'desc': 'desc',
1636                            'file': {
1637                                1722919011626770944: 'file_0',
1638                                1722919011626812928: 'file_1',
1639                                1722919011626846976: 'file_2',
1640                            },
1641                            'ref': None,
1642                            'value': 100,
1643                            'time': 1690977015000000000,
1644                            'transfer': False,
1645                        },
1646                    ],
1647                },
1648            },
1649            'weekly': {
1650                datetime: {
1651                    'positive': 100,
1652                    'negative': 51,
1653                    'total': 99,
1654                },
1655            },
1656            'monthly': {
1657                '2024-06': {
1658                    'positive': 100,
1659                    'negative': 51,
1660                    'total': 99,
1661                },
1662            },
1663            'yearly': {
1664                2024: {
1665                    'positive': 100,
1666                    'negative': 51,
1667                    'total': 99,
1668                },
1669            },
1670        }
1671        """
1672        logs = {}
1673        for account in self.accounts():
1674            for k, v in self.logs(account).items():
1675                v['time'] = k
1676                v['account'] = account
1677                if k not in logs:
1678                    logs[k] = []
1679                logs[k].append(v)
1680        if debug:
1681            print('logs', logs)
1682        y = self.daily_logs_init()
1683        for i in sorted(logs, reverse=True):
1684            dt = self.time_to_datetime(i)
1685            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
1686            weekly = dt - datetime.timedelta(days=weekday.value)
1687            monthly = f'{dt.year}-{dt.month:02d}'
1688            yearly = dt.year
1689            # daily
1690            if daily not in y['daily']:
1691                y['daily'][daily] = {
1692                    'positive': 0,
1693                    'negative': 0,
1694                    'total': 0,
1695                    'rows': [],
1696                }
1697            transfer = len(logs[i]) > 1
1698            if debug:
1699                print('logs[i]', logs[i])
1700            for z in logs[i]:
1701                if debug:
1702                    print('z', z)
1703                # daily
1704                value = z['value']
1705                if value > 0:
1706                    y['daily'][daily]['positive'] += value
1707                else:
1708                    y['daily'][daily]['negative'] += -value
1709                y['daily'][daily]['total'] += value
1710                z['transfer'] = transfer
1711                y['daily'][daily]['rows'].append(z)
1712                # weekly
1713                if weekly not in y['weekly']:
1714                    y['weekly'][weekly] = {
1715                        'positive': 0,
1716                        'negative': 0,
1717                        'total': 0,
1718                    }
1719                if value > 0:
1720                    y['weekly'][weekly]['positive'] += value
1721                else:
1722                    y['weekly'][weekly]['negative'] += -value
1723                y['weekly'][weekly]['total'] += value
1724                # monthly
1725                if monthly not in y['monthly']:
1726                    y['monthly'][monthly] = {
1727                        'positive': 0,
1728                        'negative': 0,
1729                        'total': 0,
1730                    }
1731                if value > 0:
1732                    y['monthly'][monthly]['positive'] += value
1733                else:
1734                    y['monthly'][monthly]['negative'] += -value
1735                y['monthly'][monthly]['total'] += value
1736                # yearly
1737                if yearly not in y['yearly']:
1738                    y['yearly'][yearly] = {
1739                        'positive': 0,
1740                        'negative': 0,
1741                        'total': 0,
1742                    }
1743                if value > 0:
1744                    y['yearly'][yearly]['positive'] += value
1745                else:
1746                    y['yearly'][yearly]['negative'] += -value
1747                y['yearly'][yearly]['total'] += value
1748        if debug:
1749            print('y', y)
1750        return y
1751
1752    def add_file(self, account: str, ref: int, path: str) -> int:
1753        """
1754        Adds a file reference to a specific transaction log entry in the vault.
1755
1756        Parameters:
1757        - account (str): The account number associated with the transaction log.
1758        - ref (int): The reference to the transaction log entry.
1759        - path (str): The path of the file to be added.
1760
1761        Returns:
1762        - int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1763        """
1764        if self.account_exists(account):
1765            if ref in self._vault['account'][account]['log']:
1766                no_lock = self.nolock()
1767                lock = self._lock()
1768                file_ref = self.time()
1769                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1770                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1771                if no_lock:
1772                    self.free(lock)
1773                return file_ref
1774        return 0
1775
1776    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1777        """
1778        Removes a file reference from a specific transaction log entry in the vault.
1779
1780        Parameters:
1781        - account (str): The account number associated with the transaction log.
1782        - ref (int): The reference to the transaction log entry.
1783        - file_ref (int): The reference of the file to be removed.
1784
1785        Returns:
1786        - bool: True if the file reference is successfully removed, False otherwise.
1787        """
1788        if self.account_exists(account):
1789            if ref in self._vault['account'][account]['log']:
1790                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1791                    no_lock = self.nolock()
1792                    lock = self._lock()
1793                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1794                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1795                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1796                    if no_lock:
1797                        self.free(lock)
1798                    return True
1799        return False
1800
1801    def balance(self, account: str = 1, cached: bool = True) -> int:
1802        """
1803        Calculate and return the balance of a specific account.
1804
1805        Parameters:
1806        - account (str, optional): The account number. Default is '1'.
1807        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1808
1809        Returns:
1810        - int: The balance of the account.
1811
1812        Notes:
1813        - If cached is True, the function returns the cached balance.
1814        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1815        """
1816        if cached:
1817            return self._vault['account'][account]['balance']
1818        x = 0
1819        return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1]
1820
1821    def hide(self, account, status: bool = None) -> bool:
1822        """
1823        Check or set the hide status of a specific account.
1824
1825        Parameters:
1826        - account (str): The account number.
1827        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
1828
1829        Returns:
1830        - bool: The current or updated hide status of the account.
1831
1832        Raises:
1833        None
1834
1835        Example:
1836        >>> tracker = ZakatTracker()
1837        >>> ref = tracker.track(51, 'desc', 'account1')
1838        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1839        False
1840        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1841        True
1842        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1843        True
1844        >>> tracker.hide('account1', False)
1845        False
1846        """
1847        if self.account_exists(account):
1848            if status is None:
1849                return self._vault['account'][account]['hide']
1850            self._vault['account'][account]['hide'] = status
1851            return status
1852        return False
1853
1854    def zakatable(self, account, status: bool = None) -> bool:
1855        """
1856        Check or set the zakatable status of a specific account.
1857
1858        Parameters:
1859        - account (str): The account number.
1860        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1861
1862        Returns:
1863        - bool: The current or updated zakatable status of the account.
1864
1865        Raises:
1866        None
1867
1868        Example:
1869        >>> tracker = ZakatTracker()
1870        >>> ref = tracker.track(51, 'desc', 'account1')
1871        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1872        True
1873        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1874        True
1875        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1876        True
1877        >>> tracker.zakatable('account1', False)
1878        False
1879        """
1880        if self.account_exists(account):
1881            if status is None:
1882                return self._vault['account'][account]['zakatable']
1883            self._vault['account'][account]['zakatable'] = status
1884            return status
1885        return False
1886
1887    def sub(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: str = 1,
1888            created_time_ns: int = None,
1889            debug: bool = False) \
1890            -> tuple[
1891                   int,
1892                   list[
1893                       tuple[int, int],
1894                   ],
1895               ] | tuple:
1896        """
1897        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
1898        the remaining amount will be transferred to a new transaction with a negative value.
1899
1900        Parameters:
1901        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
1902        - desc (str, optional): A description for the transaction. Defaults to an empty string.
1903        - account (str, optional): The account from which the value will be subtracted. Defaults to '1'.
1904        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
1905                                           If not provided, the current timestamp will be used.
1906        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
1907
1908        Returns:
1909        - tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1910
1911        Raises:
1912        - ValueError: The created_time_ns should be greater than zero.
1913        - ValueError: The box transaction happened again in the same nanosecond time.
1914        - ValueError: The log transaction happened again in the same nanosecond time.
1915        """
1916        if debug:
1917            print('sub', f'debug={debug}')
1918        if unscaled_value < 0:
1919            return tuple()
1920        if unscaled_value == 0:
1921            ref = self.track(unscaled_value, '', account)
1922            return ref, ref
1923        if created_time_ns is None:
1924            created_time_ns = self.time()
1925        if created_time_ns <= 0:
1926            raise ValueError('The created should be greater than zero.')
1927        no_lock = self.nolock()
1928        lock = self._lock()
1929        self.track(0, '', account)
1930        value = self.scale(unscaled_value)
1931        self._log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
1932        ids = sorted(self._vault['account'][account]['box'].keys())
1933        limit = len(ids) + 1
1934        target = value
1935        if debug:
1936            print('ids', ids)
1937        ages = []
1938        for i in range(-1, -limit, -1):
1939            if target == 0:
1940                break
1941            j = ids[i]
1942            if debug:
1943                print('i', i, 'j', j)
1944            rest = self._vault['account'][account]['box'][j]['rest']
1945            if rest >= target:
1946                self._vault['account'][account]['box'][j]['rest'] -= target
1947                self._step(Action.SUB, account, ref=j, value=target)
1948                ages.append((j, target))
1949                target = 0
1950                break
1951            elif target > rest > 0:
1952                chunk = rest
1953                target -= chunk
1954                self._vault['account'][account]['box'][j]['rest'] = 0
1955                self._step(Action.SUB, account, ref=j, value=chunk)
1956                ages.append((j, chunk))
1957        if target > 0:
1958            self.track(
1959                unscaled_value=self.unscale(-target),
1960                desc=desc,
1961                account=account,
1962                logging=False,
1963                created_time_ns=created_time_ns,
1964            )
1965            ages.append((created_time_ns, target))
1966        if no_lock:
1967            self.free(lock)
1968        return created_time_ns, ages
1969
1970    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: str, to_account: str, desc: str = '',
1971                 created_time_ns: int = None,
1972                 debug: bool = False) -> list[int]:
1973        """
1974        Transfers a specified value from one account to another.
1975
1976        Parameters:
1977        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
1978        - from_account (str): The account from which the value will be transferred.
1979        - to_account (str): The account to which the value will be transferred.
1980        - desc (str, optional): A description for the transaction. Defaults to an empty string.
1981        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
1982        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
1983
1984        Returns:
1985        - list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1986
1987        Raises:
1988        - ValueError: Transfer to the same account is forbidden.
1989        - ValueError: The created_time_ns should be greater than zero.
1990        - ValueError: The box transaction happened again in the same nanosecond time.
1991        - ValueError: The log transaction happened again in the same nanosecond time.
1992        """
1993        if debug:
1994            print('transfer', f'debug={debug}')
1995        if from_account == to_account:
1996            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1997        if unscaled_amount <= 0:
1998            return []
1999        if created_time_ns is None:
2000            created_time_ns = self.time()
2001        if created_time_ns <= 0:
2002            raise ValueError('The created should be greater than zero.')
2003        no_lock = self.nolock()
2004        lock = self._lock()
2005        (_, ages) = self.sub(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
2006        times = []
2007        source_exchange = self.exchange(from_account, created_time_ns)
2008        target_exchange = self.exchange(to_account, created_time_ns)
2009
2010        if debug:
2011            print('ages', ages)
2012
2013        for age, value in ages:
2014            target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']))
2015            if debug:
2016                print('target_amount', target_amount)
2017            # Perform the transfer
2018            if self.box_exists(to_account, age):
2019                if debug:
2020                    print('box_exists', age)
2021                capital = self._vault['account'][to_account]['box'][age]['capital']
2022                rest = self._vault['account'][to_account]['box'][age]['rest']
2023                if debug:
2024                    print(
2025                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2026                selected_age = age
2027                if rest + target_amount > capital:
2028                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
2029                    selected_age = ZakatTracker.time()
2030                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
2031                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
2032                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
2033                              created_time_ns=None, ref=None, debug=debug)
2034                times.append((age, y))
2035                continue
2036            if debug:
2037                print(
2038                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2039            y = self.track(
2040                unscaled_value=self.unscale(int(target_amount)),
2041                desc=desc,
2042                account=to_account,
2043                logging=True,
2044                created_time_ns=age,
2045                debug=debug,
2046            )
2047            times.append(y)
2048        if no_lock:
2049            self.free(lock)
2050        return times
2051
2052    def check(self,
2053              silver_gram_price: float,
2054              unscaled_nisab: float | int | decimal.Decimal = None,
2055              debug: bool = False,
2056              created_time_ns: int = None,
2057              cycle: float = None) -> tuple[bool, list[int], dict[str, float | int | str]]:
2058        """
2059        Check the eligibility for Zakat based on the given parameters.
2060
2061        Parameters:
2062        - silver_gram_price (float): The price of a gram of silver.
2063        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
2064                        If not provided, it will be calculated based on the silver_gram_price.
2065        - debug (bool, optional): Flag to enable debug mode.
2066        - created_time_ns (int, optional): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
2067        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
2068
2069        Returns:
2070        - tuple[bool, list[int], dict[str, float | int | str]]: A tuple containing a boolean indicating the eligibility for Zakat,
2071            a list of brief statistics, and a dictionary containing the Zakat plan.
2072        """
2073        if debug:
2074            print('check', f'debug={debug}')
2075        if created_time_ns is None:
2076            created_time_ns = self.time()
2077        if cycle is None:
2078            cycle = ZakatTracker.TimeCycle()
2079        if unscaled_nisab is None:
2080            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
2081        nisab = self.scale(unscaled_nisab)
2082        plan = {}
2083        below_nisab = 0
2084        brief = [0, 0, 0]
2085        valid = False
2086        if debug:
2087            print('exchanges', self.exchanges())
2088        for x in self._vault['account']:
2089            if not self.zakatable(x):
2090                continue
2091            _box = self._vault['account'][x]['box']
2092            _log = self._vault['account'][x]['log']
2093            limit = len(_box) + 1
2094            ids = sorted(self._vault['account'][x]['box'].keys())
2095            for i in range(-1, -limit, -1):
2096                j = ids[i]
2097                rest = float(_box[j]['rest'])
2098                if rest <= 0:
2099                    continue
2100                exchange = self.exchange(x, created_time_ns=self.time())
2101                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
2102                brief[0] += rest
2103                index = limit + i - 1
2104                epoch = (created_time_ns - j) / cycle
2105                if debug:
2106                    print(f'Epoch: {epoch}', _box[j])
2107                if _box[j]['last'] > 0:
2108                    epoch = (created_time_ns - _box[j]['last']) / cycle
2109                if debug:
2110                    print(f'Epoch: {epoch}')
2111                epoch = math.floor(epoch)
2112                if debug:
2113                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
2114                if epoch == 0:
2115                    continue
2116                if debug:
2117                    print('Epoch - PASSED')
2118                brief[1] += rest
2119                if rest >= nisab:
2120                    total = 0
2121                    for _ in range(epoch):
2122                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
2123                    if total > 0:
2124                        if x not in plan:
2125                            plan[x] = {}
2126                        valid = True
2127                        brief[2] += total
2128                        plan[x][index] = {
2129                            'total': total,
2130                            'count': epoch,
2131                            'box_time': j,
2132                            'box_capital': _box[j]['capital'],
2133                            'box_rest': _box[j]['rest'],
2134                            'box_last': _box[j]['last'],
2135                            'box_total': _box[j]['total'],
2136                            'box_count': _box[j]['count'],
2137                            'box_log': _log[j]['desc'],
2138                            'exchange_rate': exchange['rate'],
2139                            'exchange_time': exchange['time'],
2140                            'exchange_desc': exchange['description'],
2141                        }
2142                else:
2143                    chunk = ZakatTracker.ZakatCut(float(rest))
2144                    if chunk > 0:
2145                        if x not in plan:
2146                            plan[x] = {}
2147                        if j not in plan[x].keys():
2148                            plan[x][index] = {}
2149                        below_nisab += rest
2150                        brief[2] += chunk
2151                        plan[x][index]['below_nisab'] = chunk
2152                        plan[x][index]['total'] = chunk
2153                        plan[x][index]['count'] = epoch
2154                        plan[x][index]['box_time'] = j
2155                        plan[x][index]['box_capital'] = _box[j]['capital']
2156                        plan[x][index]['box_rest'] = _box[j]['rest']
2157                        plan[x][index]['box_last'] = _box[j]['last']
2158                        plan[x][index]['box_total'] = _box[j]['total']
2159                        plan[x][index]['box_count'] = _box[j]['count']
2160                        plan[x][index]['box_log'] = _log[j]['desc']
2161                        plan[x][index]['exchange_rate'] = exchange['rate']
2162                        plan[x][index]['exchange_time'] = exchange['time']
2163                        plan[x][index]['exchange_desc'] = exchange['description']
2164        valid = valid or below_nisab >= nisab
2165        if debug:
2166            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
2167        return valid, brief, plan
2168
2169    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict[str, dict[str, float] | bool | int | float]:
2170        """
2171        Build payment parts for the Zakat distribution.
2172
2173        Parameters:
2174        - scaled_demand (int): The total demand for payment in local currency.
2175        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
2176
2177        Returns:
2178        - dict[str, dict[str, float] | bool | int | float]: A dictionary containing the payment parts for each account. The dictionary has the following structure:
2179        {
2180            'account': {
2181                'account_id': {'balance': float, 'rate': float, 'part': float},
2182                ...
2183            },
2184            'exceed': bool,
2185            'demand': int,
2186            'total': float,
2187        }
2188        """
2189        total = 0
2190        parts = {
2191            'account': {},
2192            'exceed': False,
2193            'demand': int(round(scaled_demand)),
2194        }
2195        for x, y in self.accounts().items():
2196            if positive_only and y <= 0:
2197                continue
2198            total += float(y)
2199            exchange = self.exchange(x)
2200            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
2201        parts['total'] = total
2202        return parts
2203
2204    @staticmethod
2205    def check_payment_parts(parts: dict[str, dict[str, float] | bool | int | float], debug: bool = False) -> int:
2206        """
2207        Checks the validity of payment parts.
2208
2209        Parameters:
2210        - parts (dict[str, dict[str, float] | bool | int | float]): A dictionary containing payment parts information.
2211        - debug (bool, optional): Flag to enable debug mode.
2212
2213        Returns:
2214        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
2215
2216        Error Codes:
2217        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
2218        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
2219        3: 'part' value in parts['account'][x] is less than 0.
2220        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
2221        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
2222        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
2223        """
2224        if debug:
2225            print('check_payment_parts', f'debug={debug}')
2226        for i in ['demand', 'account', 'total', 'exceed']:
2227            if i not in parts:
2228                return 1
2229        exceed = parts['exceed']
2230        for x in parts['account']:
2231            for j in ['balance', 'rate', 'part']:
2232                if j not in parts['account'][x]:
2233                    return 2
2234                if parts['account'][x]['part'] < 0:
2235                    return 3
2236                if not exceed and parts['account'][x]['balance'] <= 0:
2237                    return 4
2238        demand = parts['demand']
2239        z = 0
2240        for _, y in parts['account'].items():
2241            if not exceed and y['part'] > y['balance']:
2242                return 5
2243            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
2244        z = round(z, 2)
2245        demand = round(demand, 2)
2246        if debug:
2247            print('check_payment_parts', f'z = {z}, demand = {demand}')
2248            print('check_payment_parts', type(z), type(demand))
2249            print('check_payment_parts', z != demand)
2250            print('check_payment_parts', str(z) != str(demand))
2251        if z != demand and str(z) != str(demand):
2252            return 6
2253        return 0
2254
2255    def zakat(self, report: tuple[bool, list[int], dict[str, float | int | str]],
2256        parts: dict[str, dict[str, float] | bool | int | float] = None, debug: bool = False) -> bool:
2257        """
2258        Perform Zakat calculation based on the given report and optional parts.
2259
2260        Parameters:
2261        - report (tuple[bool, list[int], dict[str, float | int | str]]): A tuple containing the validity of the report, the report data, and the zakat plan.
2262        - parts (dict[str, dict[str, float] | bool | int | float], optional): A dictionary containing the payment parts for the zakat.
2263        - debug (bool, optional): A flag indicating whether to print debug information.
2264
2265        Returns:
2266        - bool: True if the zakat calculation is successful, False otherwise.
2267        """
2268        if debug:
2269            print('zakat', f'debug={debug}')
2270        valid, _, plan = report
2271        if not valid:
2272            return valid
2273        parts_exist = parts is not None
2274        if parts_exist:
2275            if self.check_payment_parts(parts, debug=debug) != 0:
2276                return False
2277        if debug:
2278            print('######### zakat #######')
2279            print('parts_exist', parts_exist)
2280        no_lock = self.nolock()
2281        lock = self._lock()
2282        report_time = self.time()
2283        self._vault['report'][report_time] = report
2284        self._step(Action.REPORT, ref=report_time)
2285        created_time_ns = self.time()
2286        for x in plan:
2287            target_exchange = self.exchange(x)
2288            if debug:
2289                print(plan[x])
2290                print('-------------')
2291                print(self._vault['account'][x]['box'])
2292            ids = sorted(self._vault['account'][x]['box'].keys())
2293            if debug:
2294                print('plan[x]', plan[x])
2295            for i in plan[x].keys():
2296                j = ids[i]
2297                if debug:
2298                    print('i', i, 'j', j)
2299                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
2300                           key='last',
2301                           math_operation=MathOperation.EQUAL)
2302                self._vault['account'][x]['box'][j]['last'] = created_time_ns
2303                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
2304                self._vault['account'][x]['box'][j]['total'] += amount
2305                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
2306                           math_operation=MathOperation.ADDITION)
2307                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
2308                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
2309                           math_operation=MathOperation.ADDITION)
2310                if not parts_exist:
2311                    try:
2312                        self._vault['account'][x]['box'][j]['rest'] -= amount
2313                    except TypeError:
2314                        self._vault['account'][x]['box'][j]['rest'] -= decimal.Decimal(amount)
2315                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
2316                    #            math_operation=MathOperation.SUBTRACTION)
2317                    self._log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
2318        if parts_exist:
2319            for account, part in parts['account'].items():
2320                if part['part'] == 0:
2321                    continue
2322                if debug:
2323                    print('zakat-part', account, part['rate'])
2324                target_exchange = self.exchange(account)
2325                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
2326                self.sub(
2327                    unscaled_value=self.unscale(int(amount)),
2328                    desc='zakat-part-دفعة-زكاة',
2329                    account=account,
2330                    debug=debug,
2331                )
2332        if no_lock:
2333            self.free(lock)
2334        return True
2335
2336    def export_json(self, path: str = 'data.json') -> bool:
2337        """
2338        Exports the current state of the ZakatTracker object to a JSON file.
2339
2340        Parameters:
2341        - path (str, optional): The path where the JSON file will be saved. Default is 'data.json'.
2342
2343        Returns:
2344        - bool: True if the export is successful, False otherwise.
2345
2346        Raises:
2347        No specific exceptions are raised by this method.
2348        """
2349        with open(path, 'w', encoding='utf-8') as file:
2350            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
2351            return True
2352
2353    @staticmethod
2354    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
2355        """Splits a string at the last occurrence of a given symbol.
2356    
2357        Parameters:
2358        - data (str): The input string.
2359        - symbol (str): The symbol to split at.
2360    
2361        Returns:
2362        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
2363            the part after the last symbol. If the symbol is not found, returns (data, "").
2364        """
2365        last_symbol_index = data.rfind(symbol)
2366    
2367        if last_symbol_index != -1:
2368            before_symbol = data[:last_symbol_index]
2369            after_symbol = data[last_symbol_index + 1:]
2370            return before_symbol, after_symbol
2371        return data, ""
2372
2373    def save(self, path: str = None, hash_required: bool = True) -> bool:
2374        """
2375        Saves the ZakatTracker's current state to a camel file.
2376
2377        This method serializes the internal data (`_vault`).
2378
2379        Parameters:
2380        - path (str, optional): File path for saving. Defaults to a predefined location.
2381        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
2382
2383        Returns:
2384        - bool: True if the save operation is successful, False otherwise.
2385        """
2386        if path is None:
2387            path = self.path()
2388        # first save in tmp file
2389        temp = f'{path}.tmp'
2390        try:
2391            with open(temp, 'w', encoding='utf-8') as stream:
2392                data = camel.dump(self._vault)
2393                stream.write(data)
2394                if hash_required:
2395                    hashed = self.hash_data(data.encode())
2396                    stream.write(f'#{hashed}')
2397            # then move tmp file to original location
2398            shutil.move(temp, path)
2399            return True
2400        except (IOError, OSError) as e:
2401            print(f'Error saving file: {e}')
2402            if os.path.exists(temp):
2403                os.remove(temp)
2404            return False
2405
2406    def load(self, path: str = None, hash_required: bool = True) -> bool:
2407        """
2408        Load the current state of the ZakatTracker object from a camel file.
2409
2410        Parameters:
2411        - path (str, optional): The path where the camel file is located. If not provided, it will use the default path.
2412        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
2413
2414        Returns:
2415        - bool: True if the load operation is successful, False otherwise.
2416        """
2417        if path is None:
2418            path = self.path()
2419        try:
2420            if os.path.exists(path):
2421                with open(path, 'r', encoding='utf-8') as stream:
2422                    file = stream.read()
2423                    data, hashed = self.split_at_last_symbol(file, '#')
2424                    if hash_required:
2425                        new_hash = self.hash_data(data.encode())
2426                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
2427                    self._vault = camel.load(data)
2428                return True
2429            else:
2430                print(f'File not found: {path}')
2431                return False
2432        except (IOError, OSError) as e:
2433            print(f'Error loading file: {e}')
2434            return False
2435
2436    def import_csv_cache_path(self):
2437        """
2438        Generates the cache file path for imported CSV data.
2439
2440        This function constructs the file path where cached data from CSV imports
2441        will be stored. The cache file is a camel file (.camel extension) appended
2442        to the base path of the object.
2443
2444        Parameters:
2445        None
2446
2447        Returns:
2448        - str: The full path to the import CSV cache file.
2449
2450        Example:
2451            >>> obj = ZakatTracker('/data/reports')
2452            >>> obj.import_csv_cache_path()
2453            '/data/reports.import_csv.camel'
2454        """
2455        path = str(self.path())
2456        ext = self.ext()
2457        ext_len = len(ext)
2458        if path.endswith(f'.{ext}'):
2459            path = path[:-ext_len - 1]
2460        _, filename = os.path.split(path + f'.import_csv.{ext}')
2461        return self.base_path(filename)
2462
2463    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
2464        """
2465        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
2466
2467        Parameters:
2468        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
2469        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
2470        - debug (bool, optional): A flag indicating whether to print debug information.
2471
2472        Returns:
2473        - tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
2474                and a dictionary of bad transactions.
2475
2476        Notes:
2477        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
2478                                    are appropriate for the currency pairs involved in the conversions.
2479        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
2480            to 1.0 or the previous rate for that account.
2481        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
2482            transactions of the same account within the whole imported and existing dataset when doing `check` and
2483            `zakat` operations.
2484
2485        Example:
2486            The CSV file should have the following format, rate is optional per transaction:
2487            account, desc, value, date, rate
2488            For example:
2489            safe-45, 'Some text', 34872, 1988-06-30 00:00:00, 1
2490        """
2491        if debug:
2492            print('import_csv', f'debug={debug}')
2493        cache: list[int] = []
2494        try:
2495            with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
2496                cache = camel.load(stream.read())
2497        except:
2498            pass
2499        date_formats = [
2500            '%Y-%m-%d %H:%M:%S',
2501            '%Y-%m-%dT%H:%M:%S',
2502            '%Y-%m-%dT%H%M%S.%f',
2503            '%Y-%m-%d',
2504        ]
2505        created, found, bad = 0, 0, {}
2506        data: dict[int, list] = {}
2507        with open(path, newline='', encoding='utf-8') as f:
2508            i = 0
2509            for row in csv.reader(f, delimiter=','):
2510                i += 1
2511                hashed = hash(tuple(row))
2512                if hashed in cache:
2513                    found += 1
2514                    continue
2515                account = row[0]
2516                desc = row[1]
2517                value = float(row[2])
2518                rate = 1.0
2519                if row[4:5]:  # Empty list if index is out of range
2520                    rate = float(row[4])
2521                date: int = 0
2522                for time_format in date_formats:
2523                    try:
2524                        date = self.time(datetime.datetime.strptime(row[3], time_format))
2525                        break
2526                    except:
2527                        pass
2528                if date <= 0:
2529                    bad[i] = row + ['invalid date']
2530                if value == 0:
2531                    bad[i] = row + ['invalid value']
2532                    continue
2533                if date not in data:
2534                    data[date] = []
2535                data[date].append((i, account, desc, value, date, rate, hashed))
2536
2537        if debug:
2538            print('import_csv', len(data))
2539
2540        if bad:
2541            return created, found, bad
2542
2543        no_lock = self.nolock()
2544        lock = self._lock()
2545        for date, rows in sorted(data.items()):
2546            try:
2547                len_rows = len(rows)
2548                if len_rows == 1:
2549                    (_, account, desc, unscaled_value, date, rate, hashed) = rows[0]
2550                    value = self.unscale(
2551                        unscaled_value,
2552                        decimal_places=scale_decimal_places,
2553                    ) if scale_decimal_places > 0 else unscaled_value
2554                    if rate > 0:
2555                        self.exchange(account=account, created_time_ns=date, rate=rate)
2556                    if value > 0:
2557                        self.track(unscaled_value=value, desc=desc, account=account, logging=True, created_time_ns=date)
2558                    elif value < 0:
2559                        self.sub(unscaled_value=-value, desc=desc, account=account, created_time_ns=date)
2560                    created += 1
2561                    cache.append(hashed)
2562                    continue
2563                if debug:
2564                    print('-- Duplicated time detected', date, 'len', len_rows)
2565                    print(rows)
2566                    print('---------------------------------')
2567                # If records are found at the same time with different accounts in the same amount
2568                # (one positive and the other negative), this indicates it is a transfer.
2569                if len_rows != 2:
2570                    raise Exception(f'more than two transactions({len_rows}) at the same time')
2571                (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0]
2572                (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1]
2573                if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(
2574                        unscaled_value2) or date1 != date2:
2575                    raise Exception('invalid transfer')
2576                if rate1 > 0:
2577                    self.exchange(account1, created_time_ns=date1, rate=rate1)
2578                if rate2 > 0:
2579                    self.exchange(account2, created_time_ns=date2, rate=rate2)
2580                value1 = self.unscale(
2581                    unscaled_value1,
2582                    decimal_places=scale_decimal_places,
2583                ) if scale_decimal_places > 0 else unscaled_value1
2584                value2 = self.unscale(
2585                    unscaled_value2,
2586                    decimal_places=scale_decimal_places,
2587                ) if scale_decimal_places > 0 else unscaled_value2
2588                values = {
2589                    value1: account1,
2590                    value2: account2,
2591                }
2592                self.transfer(
2593                    unscaled_amount=abs(value1),
2594                    from_account=values[min(values.keys())],
2595                    to_account=values[max(values.keys())],
2596                    desc=desc1,
2597                    created_time_ns=date1,
2598                )
2599            except Exception as e:
2600                for (i, account, desc, value, date, rate, _) in rows:
2601                    bad[i] = (account, desc, value, date, rate, e)
2602                break
2603        with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
2604            stream.write(camel.dump(cache))
2605        if no_lock:
2606            self.free(lock)
2607        y = created, found, bad
2608        if debug:
2609            debug_path = f'{self.import_csv_cache_path()}.debug.json'
2610            with open(debug_path, 'w', encoding='utf-8') as file:
2611                json.dump(y, file, indent=4, cls=JSONEncoder)
2612                print(f'generated debug report @ `{debug_path}`...')
2613        return y
2614
2615    ########
2616    # TESTS #
2617    #######
2618
2619    @staticmethod
2620    def human_readable_size(size: float, decimal_places: int = 2) -> str:
2621        """
2622        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
2623
2624        This function iterates through progressively larger units of information
2625        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
2626        range that can be expressed with a reasonable number before the unit.
2627
2628        Parameters:
2629        - size (float): The size in bytes to convert.
2630        - decimal_places (int, optional): The number of decimal places to display
2631            in the result. Defaults to 2.
2632
2633        Returns:
2634        - str: A string representation of the size in a human-readable format,
2635            rounded to the specified number of decimal places. For example:
2636                - '1.50 KB' (1536 bytes)
2637                - '23.00 MB' (24117248 bytes)
2638                - '1.23 GB' (1325899906 bytes)
2639        """
2640        if type(size) not in (float, int):
2641            raise TypeError('size must be a float or integer')
2642        if type(decimal_places) != int:
2643            raise TypeError('decimal_places must be an integer')
2644        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
2645            if size < 1024.0:
2646                break
2647            size /= 1024.0
2648        return f'{size:.{decimal_places}f} {unit}'
2649
2650    @staticmethod
2651    def get_dict_size(obj: dict, seen: set = None) -> float:
2652        """
2653        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
2654
2655        This function traverses the dictionary structure, accounting for the size of keys, values,
2656        and any nested objects. It handles various data types commonly found in dictionaries
2657        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
2658        of circular references.
2659
2660        Parameters:
2661        - obj (dict): The dictionary whose size is to be calculated.
2662        - seen (set, optional): A set used internally to track visited objects
2663                             and avoid circular references. Defaults to None.
2664
2665        Returns:
2666         - float: An approximate size of the dictionary and its contents in bytes.
2667
2668        Notes:
2669        - This function is a method of the `ZakatTracker` class and is likely used to
2670          estimate the memory footprint of data structures relevant to Zakat calculations.
2671        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
2672          not account for all memory overhead depending on the Python implementation.
2673        - Circular references are handled to prevent infinite recursion.
2674        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
2675        - String sizes are estimated based on character length and encoding.
2676        """
2677        size = 0
2678        if seen is None:
2679            seen = set()
2680
2681        obj_id = id(obj)
2682        if obj_id in seen:
2683            return 0
2684
2685        seen.add(obj_id)
2686        size += sys.getsizeof(obj)
2687
2688        if isinstance(obj, dict):
2689            for k, v in obj.items():
2690                size += ZakatTracker.get_dict_size(k, seen)
2691                size += ZakatTracker.get_dict_size(v, seen)
2692        elif isinstance(obj, (list, tuple, set, frozenset)):
2693            for item in obj:
2694                size += ZakatTracker.get_dict_size(item, seen)
2695        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2696            pass  # Basic numbers have a fixed size, so nothing to add here
2697        elif isinstance(obj, str):  # Handle strings
2698            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2699        return size
2700
2701    @staticmethod
2702    def duration_from_nanoseconds(ns: int,
2703                                  show_zeros_in_spoken_time: bool = False,
2704                                  spoken_time_separator=',',
2705                                  millennia: str = 'Millennia',
2706                                  century: str = 'Century',
2707                                  years: str = 'Years',
2708                                  days: str = 'Days',
2709                                  hours: str = 'Hours',
2710                                  minutes: str = 'Minutes',
2711                                  seconds: str = 'Seconds',
2712                                  milli_seconds: str = 'MilliSeconds',
2713                                  micro_seconds: str = 'MicroSeconds',
2714                                  nano_seconds: str = 'NanoSeconds',
2715                                  ) -> tuple:
2716        """
2717        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2718        Convert NanoSeconds to Human Readable Time Format.
2719        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2720        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2721        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2722        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2723
2724        INPUT : ms (AKA: MilliSeconds)
2725        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2726        OUTPUT Variables: time_lapsed, spoken_time
2727
2728        Example  Input: duration_from_nanoseconds(ns)
2729        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
2730        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')
2731        duration_from_nanoseconds(1234567890123456789012)
2732        """
2733        us, ns = divmod(ns, 1000)
2734        ms, us = divmod(us, 1000)
2735        s, ms = divmod(ms, 1000)
2736        m, s = divmod(s, 60)
2737        h, m = divmod(m, 60)
2738        d, h = divmod(h, 24)
2739        y, d = divmod(d, 365)
2740        c, y = divmod(y, 100)
2741        n, c = divmod(c, 10)
2742        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}'
2743        spoken_time_part = []
2744        if n > 0 or show_zeros_in_spoken_time:
2745            spoken_time_part.append(f'{n: 3d} {millennia}')
2746        if c > 0 or show_zeros_in_spoken_time:
2747            spoken_time_part.append(f'{c: 4d} {century}')
2748        if y > 0 or show_zeros_in_spoken_time:
2749            spoken_time_part.append(f'{y: 3d} {years}')
2750        if d > 0 or show_zeros_in_spoken_time:
2751            spoken_time_part.append(f'{d: 4d} {days}')
2752        if h > 0 or show_zeros_in_spoken_time:
2753            spoken_time_part.append(f'{h: 2d} {hours}')
2754        if m > 0 or show_zeros_in_spoken_time:
2755            spoken_time_part.append(f'{m: 2d} {minutes}')
2756        if s > 0 or show_zeros_in_spoken_time:
2757            spoken_time_part.append(f'{s: 2d} {seconds}')
2758        if ms > 0 or show_zeros_in_spoken_time:
2759            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
2760        if us > 0 or show_zeros_in_spoken_time:
2761            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
2762        if ns > 0 or show_zeros_in_spoken_time:
2763            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
2764        return time_lapsed, spoken_time_separator.join(spoken_time_part)
2765
2766    @staticmethod
2767    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2768        """
2769        Convert a specific day, month, and year into a timestamp.
2770
2771        Parameters:
2772        - day (int): The day of the month.
2773        - month (int, optional): The month of the year. Default is 6 (June).
2774        - year (int, optional): The year. Default is 2024.
2775
2776        Returns:
2777        - int: The timestamp representing the given day, month, and year.
2778
2779        Note:
2780        - This method assumes the default month and year if not provided.
2781        """
2782        return ZakatTracker.time(datetime.datetime(year, month, day))
2783
2784    @staticmethod
2785    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2786        """
2787        Generate a random date between two given dates.
2788
2789        Parameters:
2790        - start_date (datetime.datetime): The start date from which to generate a random date.
2791        - end_date (datetime.datetime): The end date until which to generate a random date.
2792
2793        Returns:
2794        - datetime.datetime: A random date between the start_date and end_date.
2795        """
2796        time_between_dates = end_date - start_date
2797        days_between_dates = time_between_dates.days
2798        random_number_of_days = random.randrange(days_between_dates)
2799        return start_date + datetime.timedelta(days=random_number_of_days)
2800
2801    @staticmethod
2802    def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False,
2803                                 debug: bool = False) -> int:
2804        """
2805        Generate a random CSV file with specified parameters.
2806        The function generates a CSV file at the specified path with the given count of rows.
2807        Each row contains a randomly generated account, description, value, and date.
2808        The value is randomly generated between 1000 and 100000,
2809        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2810        If the row number is not divisible by 13, the value is multiplied by -1.
2811
2812        Parameters:
2813        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
2814        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
2815        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
2816        - debug (bool, optional): A flag indicating whether to print debug information.
2817
2818        Returns:
2819        None
2820        """
2821        if debug:
2822            print('generate_random_csv_file', f'debug={debug}')
2823        i = 0
2824        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
2825            writer = csv.writer(csvfile)
2826            for i in range(count):
2827                account = f'acc-{random.randint(1, count)}'
2828                desc = f'Some text {random.randint(1, count)}'
2829                value = random.randint(1000, 100000)
2830                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2831                                                         datetime.datetime(2023, 12, 31)).strftime('%Y-%m-%d %H:%M:%S')
2832                if not i % 13 == 0:
2833                    value *= -1
2834                row = [account, desc, value, date]
2835                if with_rate:
2836                    rate = random.randint(1, 100) * 0.12
2837                    if debug:
2838                        print('before-append', row)
2839                    row.append(rate)
2840                    if debug:
2841                        print('after-append', row)
2842                writer.writerow(row)
2843                i = i + 1
2844        return i
2845
2846    @staticmethod
2847    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
2848        """
2849        Creates a list of random integers whose sum does not exceed the specified maximum.
2850
2851        Parameters:
2852        - max_sum (int): The maximum allowed sum of the list elements.
2853        - min_value (int, optional): The minimum possible value for an element (inclusive).
2854        - max_value (int, optional): The maximum possible value for an element (inclusive).
2855
2856        Returns:
2857        - A list of random integers.
2858        """
2859        result = []
2860        current_sum = 0
2861
2862        while current_sum < max_sum:
2863            # Calculate the remaining space for the next element
2864            remaining_sum = max_sum - current_sum
2865            # Determine the maximum possible value for the next element
2866            next_max_value = min(remaining_sum, max_value)
2867            # Generate a random element within the allowed range
2868            next_element = random.randint(min_value, next_max_value)
2869            result.append(next_element)
2870            current_sum += next_element
2871
2872        return result
2873
2874    def _test_core(self, restore: bool = False, debug: bool = False):
2875
2876        if debug:
2877            random.seed(1234567890)
2878
2879        test_cases = [
2880            datetime.datetime(1, 1, 1),
2881            datetime.datetime(1970, 1, 1),
2882            datetime.datetime(1969, 12, 31),
2883            datetime.datetime.now(),
2884            datetime.datetime(9999, 12, 31, 23, 59, 59),
2885        ]
2886        
2887        for test_date in test_cases:
2888            timestamp = ZakatTracker.time(test_date)
2889            converted = ZakatTracker.time_to_datetime(timestamp)
2890            if debug:
2891                print(f'{timestamp} <=> {converted}')
2892            assert timestamp > 0
2893            assert test_date.year == converted.year
2894            assert test_date.month == converted.month
2895            assert test_date.day == converted.day
2896            assert test_date.hour == converted.hour
2897            assert test_date.minute == converted.minute
2898            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
2899
2900        # sanity check - random forward time
2901
2902        xlist = []
2903        limit = 1000
2904        for _ in range(limit):
2905            y = ZakatTracker.time()
2906            z = '-'
2907            if y not in xlist:
2908                xlist.append(y)
2909            else:
2910                z = 'x'
2911            if debug:
2912                print(z, y)
2913        xx = len(xlist)
2914        if debug:
2915            print('count', xx, ' - unique: ', (xx / limit) * 100, '%')
2916        assert limit == xx
2917
2918        # sanity check - convert date since 1AD to 9999AD
2919
2920        for year in range(1, 10_000):
2921            ns = ZakatTracker.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45', '%Y-%m-%d %H:%M:%S'))
2922            date = ZakatTracker.time_to_datetime(ns)
2923            if debug:
2924                print(date)
2925            assert ns > 0
2926            assert date.year == year
2927            assert date.month == 12
2928            assert date.day == 30
2929            assert date.hour == 18
2930            assert date.minute == 30
2931            assert date.second in [44, 45]
2932
2933        # test ZakatTracker.split_at_last_symbol
2934
2935        test_cases = [
2936            ("This is a string @ with a symbol.", '@', ("This is a string ", " with a symbol.")),
2937            ("No symbol here.", '$', ("No symbol here.", "")),
2938            ("Multiple $ symbols $ in the string.", '$', ("Multiple $ symbols ", " in the string.")),
2939            ("Here is a symbol%", '%', ("Here is a symbol", "")),
2940            ("@only a symbol", '@', ("", "only a symbol")),
2941            ("", '#', ("", "")),
2942            ("test/test/test.txt", '/', ("test/test", "test.txt")),
2943            ("abc#def#ghi", "#", ("abc#def", "ghi")),
2944            ("abc", "#", ("abc", ""))
2945        ]
2946        
2947        for data, symbol, expected in test_cases:
2948            result = ZakatTracker.split_at_last_symbol(data, symbol)
2949            assert result == expected, f"Test failed for data='{data}', symbol='{symbol}'. Expected {expected}, got {result}"
2950
2951        # human_readable_size
2952
2953        assert ZakatTracker.human_readable_size(0) == '0.00 B'
2954        assert ZakatTracker.human_readable_size(512) == '512.00 B'
2955        assert ZakatTracker.human_readable_size(1023) == '1023.00 B'
2956
2957        assert ZakatTracker.human_readable_size(1024) == '1.00 KB'
2958        assert ZakatTracker.human_readable_size(2048) == '2.00 KB'
2959        assert ZakatTracker.human_readable_size(5120) == '5.00 KB'
2960
2961        assert ZakatTracker.human_readable_size(1024 ** 2) == '1.00 MB'
2962        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == '2.50 MB'
2963
2964        assert ZakatTracker.human_readable_size(1024 ** 3) == '1.00 GB'
2965        assert ZakatTracker.human_readable_size(1024 ** 4) == '1.00 TB'
2966        assert ZakatTracker.human_readable_size(1024 ** 5) == '1.00 PB'
2967
2968        assert ZakatTracker.human_readable_size(1536, decimal_places=0) == '2 KB'
2969        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == '2.5 MB'
2970        assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == '1.150 GB'
2971
2972        try:
2973            # noinspection PyTypeChecker
2974            ZakatTracker.human_readable_size('not a number')
2975            assert False, 'Expected TypeError for invalid input'
2976        except TypeError:
2977            pass
2978
2979        try:
2980            # noinspection PyTypeChecker
2981            ZakatTracker.human_readable_size(1024, decimal_places='not an int')
2982            assert False, 'Expected TypeError for invalid decimal_places'
2983        except TypeError:
2984            pass
2985
2986        # get_dict_size
2987        assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), 'Empty dictionary size mismatch'
2988        assert ZakatTracker.get_dict_size({'a': 1, 'b': 2.5, 'c': True}) != sys.getsizeof({}), 'Not Empty dictionary'
2989
2990        # number scale
2991        error = 0
2992        total = 0
2993        for sign in ['', '-']:
2994            for max_i, max_j, decimal_places in [
2995                (101, 101, 2),  # fiat currency minimum unit took 2 decimal places
2996                (1, 1_000, 8),  # cryptocurrency like Satoshi in Bitcoin took 8 decimal places
2997                (1, 1_000, 18)  # cryptocurrency like Wei in Ethereum took 18 decimal places
2998            ]:
2999                for return_type in (
3000                        float,
3001                        decimal.Decimal,
3002                ):
3003                    for i in range(max_i):
3004                        for j in range(max_j):
3005                            total += 1
3006                            num_str = f'{sign}{i}.{j:0{decimal_places}d}'
3007                            num = return_type(num_str)
3008                            scaled = self.scale(num, decimal_places=decimal_places)
3009                            unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places)
3010                            if debug:
3011                                print(
3012                                    f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}')
3013                            if unscaled != num:
3014                                if debug:
3015                                    print('***** SCALE ERROR *****')
3016                                error += 1
3017        if debug:
3018            print(f'total: {total}, error({error}): {100 * error / total}%')
3019        assert error == 0
3020
3021        # test lock
3022
3023        assert self.nolock()
3024        assert self._history() is True
3025        lock = self.lock()
3026        assert lock > 0
3027        failed = False
3028        try:
3029            self.lock()
3030        except:
3031            failed = True
3032        assert failed
3033        assert self.free(lock)
3034        assert not self.free(lock)
3035
3036        table = {
3037            1: [
3038                (0, 10, 1000, 1000, 1000, 1, 1),
3039                (0, 20, 3000, 3000, 3000, 2, 2),
3040                (0, 30, 6000, 6000, 6000, 3, 3),
3041                (1, 15, 4500, 4500, 4500, 3, 4),
3042                (1, 50, -500, -500, -500, 4, 5),
3043                (1, 100, -10500, -10500, -10500, 5, 6),
3044            ],
3045            'wallet': [
3046                (1, 90, -9000, -9000, -9000, 1, 1),
3047                (0, 100, 1000, 1000, 1000, 2, 2),
3048                (1, 190, -18000, -18000, -18000, 3, 3),
3049                (0, 1000, 82000, 82000, 82000, 4, 4),
3050            ],
3051        }
3052        for x in table:
3053            for y in table[x]:
3054                lock = self.lock()
3055                if y[0] == 0:
3056                    ref = self.track(
3057                        unscaled_value=y[1],
3058                        desc='test-add',
3059                        account=x,
3060                        logging=True,
3061                        created_time_ns=ZakatTracker.time(),
3062                        debug=debug,
3063                    )
3064                else:
3065                    (ref, z) = self.sub(
3066                        unscaled_value=y[1],
3067                        desc='test-sub',
3068                        account=x,
3069                        created_time_ns=ZakatTracker.time(),
3070                    )
3071                    if debug:
3072                        print('_sub', z, ZakatTracker.time())
3073                assert ref != 0
3074                assert len(self._vault['account'][x]['log'][ref]['file']) == 0
3075                for i in range(3):
3076                    file_ref = self.add_file(x, ref, 'file_' + str(i))
3077                    time.sleep(0.0000001)
3078                    assert file_ref != 0
3079                    if debug:
3080                        print('ref', ref, 'file', file_ref)
3081                    assert len(self._vault['account'][x]['log'][ref]['file']) == i + 1
3082                file_ref = self.add_file(x, ref, 'file_' + str(3))
3083                assert self.remove_file(x, ref, file_ref)
3084                daily_logs = self.daily_logs(debug=debug)
3085                if debug:
3086                    print('daily_logs', daily_logs)
3087                for k, v in daily_logs.items():
3088                    assert k
3089                    assert v
3090                z = self.balance(x)
3091                if debug:
3092                    print('debug-0', z, y)
3093                assert z == y[2]
3094                z = self.balance(x, False)
3095                if debug:
3096                    print('debug-1', z, y[3])
3097                assert z == y[3]
3098                o = self._vault['account'][x]['log']
3099                z = 0
3100                for i in o:
3101                    z += o[i]['value']
3102                if debug:
3103                    print('debug-2', z, type(z))
3104                    print('debug-2', y[4], type(y[4]))
3105                assert z == y[4]
3106                if debug:
3107                    print('debug-2 - PASSED')
3108                assert self.box_size(x) == y[5]
3109                assert self.log_size(x) == y[6]
3110                assert not self.nolock()
3111                self.free(lock)
3112                assert self.nolock()
3113            assert self.boxes(x) != {}
3114            assert self.logs(x) != {}
3115
3116            assert not self.hide(x)
3117            assert self.hide(x, False) is False
3118            assert self.hide(x) is False
3119            assert self.hide(x, True)
3120            assert self.hide(x)
3121
3122            assert self.zakatable(x)
3123            assert self.zakatable(x, False) is False
3124            assert self.zakatable(x) is False
3125            assert self.zakatable(x, True)
3126            assert self.zakatable(x)
3127
3128        if restore is True:
3129            # invalid restore point
3130            for lock in [0, time.time_ns(), ZakatTracker.time()]:
3131                failed = False
3132                try:
3133                    self.recall(True, lock)
3134                except:
3135                    failed = True
3136                assert failed
3137            count = len(self._vault['history'])
3138            if debug:
3139                print('history-count', count)
3140            assert count == 10
3141            # try mode
3142            for _ in range(count):
3143                assert self.recall(True, debug=debug)
3144            count = len(self._vault['history'])
3145            if debug:
3146                print('history-count', count)
3147            assert count == 10
3148            _accounts = list(table.keys())
3149            accounts_limit = len(_accounts) + 1
3150            for i in range(-1, -accounts_limit, -1):
3151                account = _accounts[i]
3152                if debug:
3153                    print(account, len(table[account]))
3154                transaction_limit = len(table[account]) + 1
3155                for j in range(-1, -transaction_limit, -1):
3156                    row = table[account][j]
3157                    if debug:
3158                        print(row, self.balance(account), self.balance(account, False))
3159                    assert self.balance(account) == self.balance(account, False)
3160                    assert self.balance(account) == row[2]
3161                    assert self.recall(False, debug=debug)
3162            assert self.recall(False, debug=debug) is False
3163            count = len(self._vault['history'])
3164            if debug:
3165                print('history-count', count)
3166            assert count == 0
3167            self.reset()
3168
3169    def test(self, debug: bool = False) -> bool:
3170        if debug:
3171            print('test', f'debug={debug}')
3172        try:
3173
3174            self._test_core(True, debug)
3175            self._test_core(False, debug)
3176
3177            assert self._history()
3178
3179            # Not allowed for duplicate transactions in the same account and time
3180
3181            created = ZakatTracker.time()
3182            self.track(100, 'test-1', 'same', True, created)
3183            failed = False
3184            try:
3185                self.track(50, 'test-1', 'same', True, created)
3186            except:
3187                failed = True
3188            assert failed is True
3189
3190            self.reset()
3191
3192            # Same account transfer
3193            for x in [1, 'a', True, 1.8, None]:
3194                failed = False
3195                try:
3196                    self.transfer(1, x, x, 'same-account', debug=debug)
3197                except:
3198                    failed = True
3199                assert failed is True
3200
3201            # Always preserve box age during transfer
3202
3203            series: list[tuple] = [
3204                (30, 4),
3205                (60, 3),
3206                (90, 2),
3207            ]
3208            case = {
3209                3000: {
3210                    'series': series,
3211                    'rest': 15000,
3212                },
3213                6000: {
3214                    'series': series,
3215                    'rest': 12000,
3216                },
3217                9000: {
3218                    'series': series,
3219                    'rest': 9000,
3220                },
3221                18000: {
3222                    'series': series,
3223                    'rest': 0,
3224                },
3225                27000: {
3226                    'series': series,
3227                    'rest': -9000,
3228                },
3229                36000: {
3230                    'series': series,
3231                    'rest': -18000,
3232                },
3233            }
3234
3235            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
3236
3237            for total in case:
3238                if debug:
3239                    print('--------------------------------------------------------')
3240                    print(f'case[{total}]', case[total])
3241                for x in case[total]['series']:
3242                    self.track(
3243                        unscaled_value=x[0],
3244                        desc=f'test-{x} ages',
3245                        account='ages',
3246                        logging=True,
3247                        created_time_ns=selected_time * x[1],
3248                    )
3249
3250                unscaled_total = self.unscale(total)
3251                if debug:
3252                    print('unscaled_total', unscaled_total)
3253                refs = self.transfer(
3254                    unscaled_amount=unscaled_total,
3255                    from_account='ages',
3256                    to_account='future',
3257                    desc='Zakat Movement',
3258                    debug=debug,
3259                )
3260
3261                if debug:
3262                    print('refs', refs)
3263
3264                ages_cache_balance = self.balance('ages')
3265                ages_fresh_balance = self.balance('ages', False)
3266                rest = case[total]['rest']
3267                if debug:
3268                    print('source', ages_cache_balance, ages_fresh_balance, rest)
3269                assert ages_cache_balance == rest
3270                assert ages_fresh_balance == rest
3271
3272                future_cache_balance = self.balance('future')
3273                future_fresh_balance = self.balance('future', False)
3274                if debug:
3275                    print('target', future_cache_balance, future_fresh_balance, total)
3276                    print('refs', refs)
3277                assert future_cache_balance == total
3278                assert future_fresh_balance == total
3279
3280                # TODO: check boxes times for `ages` should equal box times in `future`
3281                for ref in self._vault['account']['ages']['box']:
3282                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
3283                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
3284                    future_capital = 0
3285                    if ref in self._vault['account']['future']['box']:
3286                        future_capital = self._vault['account']['future']['box'][ref]['capital']
3287                    future_rest = 0
3288                    if ref in self._vault['account']['future']['box']:
3289                        future_rest = self._vault['account']['future']['box'][ref]['rest']
3290                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
3291                        if debug:
3292                            print('================================================================')
3293                            print('ages', ages_capital, ages_rest)
3294                            print('future', future_capital, future_rest)
3295                        if ages_rest == 0:
3296                            assert ages_capital == future_capital
3297                        elif ages_rest < 0:
3298                            assert -ages_capital == future_capital
3299                        elif ages_rest > 0:
3300                            assert ages_capital == ages_rest + future_capital
3301                self.reset()
3302                assert len(self._vault['history']) == 0
3303
3304            assert self._history()
3305            assert self._history(False) is False
3306            assert self._history() is False
3307            assert self._history(True)
3308            assert self._history()
3309            if debug:
3310                print('####################################################################')
3311
3312            transaction = [
3313                (
3314                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
3315                    2000, 2000, 2000, 1, 1,
3316                ),
3317                (
3318                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
3319                    75000, 75000, 75000, 1, 1,
3320                ),
3321                (
3322                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
3323                    60000, 60000, 60000, 1, 1,
3324                ),
3325            ]
3326            for z in transaction:
3327                lock = self.lock()
3328                x = z[1]
3329                y = z[2]
3330                self.transfer(
3331                    unscaled_amount=z[0],
3332                    from_account=x,
3333                    to_account=y,
3334                    desc='test-transfer',
3335                    debug=debug,
3336                )
3337                zz = self.balance(x)
3338                if debug:
3339                    print(zz, z)
3340                assert zz == z[3]
3341                xx = self.accounts()[x]
3342                assert xx == z[3]
3343                assert self.balance(x, False) == z[4]
3344                assert xx == z[4]
3345
3346                s = 0
3347                log = self._vault['account'][x]['log']
3348                for i in log:
3349                    s += log[i]['value']
3350                if debug:
3351                    print('s', s, 'z[5]', z[5])
3352                assert s == z[5]
3353
3354                assert self.box_size(x) == z[6]
3355                assert self.log_size(x) == z[7]
3356
3357                yy = self.accounts()[y]
3358                assert self.balance(y) == z[8]
3359                assert yy == z[8]
3360                assert self.balance(y, False) == z[9]
3361                assert yy == z[9]
3362
3363                s = 0
3364                log = self._vault['account'][y]['log']
3365                for i in log:
3366                    s += log[i]['value']
3367                assert s == z[10]
3368
3369                assert self.box_size(y) == z[11]
3370                assert self.log_size(y) == z[12]
3371                assert self.free(lock)
3372
3373            if debug:
3374                pp().pprint(self.check(2.17))
3375
3376            assert self.nolock()
3377            history_count = len(self._vault['history'])
3378            if debug:
3379                print('history-count', history_count)
3380            transaction_count = len(transaction)
3381            assert history_count == transaction_count
3382            assert not self.free(ZakatTracker.time())
3383            assert self.free(self.lock())
3384            assert self.nolock()
3385            assert len(self._vault['history']) == transaction_count
3386
3387            # storage
3388
3389            _path = self.path(f'./zakat_test_db/test.{self.ext()}')
3390            if os.path.exists(_path):
3391                os.remove(_path)
3392            for hashed in [False, True]:
3393                self.save(hash_required=hashed)
3394                assert os.path.getsize(_path) > 0
3395                self.reset()
3396                assert self.recall(False, debug=debug) is False
3397                for hash_required in [False, True]:
3398                    if debug:
3399                        print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
3400                    self.load(hash_required=hashed and hash_required)
3401                    assert self._vault['account'] is not None
3402                if hashed:
3403                    continue
3404                failed = False
3405                try:
3406                    hash_required = True
3407                    if debug:
3408                        print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
3409                    self.load(hash_required=True)
3410                except:
3411                    failed = True
3412                assert failed
3413
3414            # recall
3415
3416            assert self.nolock()
3417            assert len(self._vault['history']) == 3
3418            assert self.recall(False, debug=debug) is True
3419            assert len(self._vault['history']) == 2
3420            assert self.recall(False, debug=debug) is True
3421            assert len(self._vault['history']) == 1
3422            assert self.recall(False, debug=debug) is True
3423            assert len(self._vault['history']) == 0
3424            assert self.recall(False, debug=debug) is False
3425            assert len(self._vault['history']) == 0
3426
3427            # exchange
3428
3429            self.exchange('cash', 25, 3.75, '2024-06-25')
3430            self.exchange('cash', 22, 3.73, '2024-06-22')
3431            self.exchange('cash', 15, 3.69, '2024-06-15')
3432            self.exchange('cash', 10, 3.66)
3433
3434            assert self.nolock()
3435
3436            for i in range(1, 30):
3437                exchange = self.exchange('cash', i)
3438                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3439                if debug:
3440                    print(i, rate, description, created)
3441                assert created
3442                if i < 10:
3443                    assert rate == 1
3444                    assert description is None
3445                elif i == 10:
3446                    assert rate == 3.66
3447                    assert description is None
3448                elif i < 15:
3449                    assert rate == 3.66
3450                    assert description is None
3451                elif i == 15:
3452                    assert rate == 3.69
3453                    assert description is not None
3454                elif i < 22:
3455                    assert rate == 3.69
3456                    assert description is not None
3457                elif i == 22:
3458                    assert rate == 3.73
3459                    assert description is not None
3460                elif i >= 25:
3461                    assert rate == 3.75
3462                    assert description is not None
3463                exchange = self.exchange('bank', i)
3464                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3465                if debug:
3466                    print(i, rate, description, created)
3467                assert created
3468                assert rate == 1
3469                assert description is None
3470
3471            assert len(self._vault['exchange']) == 1
3472            assert len(self.exchanges()) == 1
3473            self._vault['exchange'].clear()
3474            assert len(self._vault['exchange']) == 0
3475            assert len(self.exchanges()) == 0
3476            self.reset()
3477
3478            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
3479            self.exchange('cash', ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
3480            self.exchange('cash', ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
3481            self.exchange('cash', ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
3482            self.exchange('cash', ZakatTracker.day_to_time(10), 3.66)
3483
3484            assert self.nolock()
3485
3486            for i in [x * 0.12 for x in range(-15, 21)]:
3487                if i <= 0:
3488                    assert len(self.exchange('test', ZakatTracker.time(), i, f'range({i})')) == 0
3489                else:
3490                    assert len(self.exchange('test', ZakatTracker.time(), i, f'range({i})')) > 0
3491
3492            assert self.nolock()
3493
3494           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
3495            for i in range(1, 31):
3496                timestamp_ns = ZakatTracker.day_to_time(i)
3497                exchange = self.exchange('cash', timestamp_ns)
3498                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3499                if debug:
3500                    print(i, rate, description, created)
3501                assert created
3502                if i < 10:
3503                    assert rate == 1
3504                    assert description is None
3505                elif i == 10:
3506                    assert rate == 3.66
3507                    assert description is None
3508                elif i < 15:
3509                    assert rate == 3.66
3510                    assert description is None
3511                elif i == 15:
3512                    assert rate == 3.69
3513                    assert description is not None
3514                elif i < 22:
3515                    assert rate == 3.69
3516                    assert description is not None
3517                elif i == 22:
3518                    assert rate == 3.73
3519                    assert description is not None
3520                elif i >= 25:
3521                    assert rate == 3.75
3522                    assert description is not None
3523                exchange = self.exchange('bank', i)
3524                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3525                if debug:
3526                    print(i, rate, description, created)
3527                assert created
3528                assert rate == 1
3529                assert description is None
3530
3531            assert self.nolock()
3532
3533            self.reset()
3534
3535            # test transfer between accounts with different exchange rate
3536
3537            a_SAR = 'Bank (SAR)'
3538            b_USD = 'Bank (USD)'
3539            c_SAR = 'Safe (SAR)'
3540            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
3541            for case in [
3542                (0, a_SAR, 'SAR Gift', 1000, 100000),
3543                (1, a_SAR, 1),
3544                (0, b_USD, 'USD Gift', 500, 50000),
3545                (1, b_USD, 1),
3546                (2, b_USD, 3.75),
3547                (1, b_USD, 3.75),
3548                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
3549                (0, c_SAR, 'Salary', 750, 75000),
3550                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
3551                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
3552            ]:
3553                if debug:
3554                    print('case', case)
3555                match (case[0]):
3556                    case 0:  # track
3557                        _, account, desc, x, balance = case
3558                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
3559
3560                        cached_value = self.balance(account, cached=True)
3561                        fresh_value = self.balance(account, cached=False)
3562                        if debug:
3563                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
3564                        assert cached_value == balance
3565                        assert fresh_value == balance
3566                    case 1:  # check-exchange
3567                        _, account, expected_rate = case
3568                        t_exchange = self.exchange(account, created_time_ns=ZakatTracker.time(), debug=debug)
3569                        if debug:
3570                            print('t-exchange', t_exchange)
3571                        assert t_exchange['rate'] == expected_rate
3572                    case 2:  # do-exchange
3573                        _, account, rate = case
3574                        self.exchange(account, rate=rate, debug=debug)
3575                        b_exchange = self.exchange(account, created_time_ns=ZakatTracker.time(), debug=debug)
3576                        if debug:
3577                            print('b-exchange', b_exchange)
3578                        assert b_exchange['rate'] == rate
3579                    case 3:  # transfer
3580                        _, x, a, b, desc, a_balance, b_balance = case
3581                        self.transfer(x, a, b, desc, debug=debug)
3582
3583                        cached_value = self.balance(a, cached=True)
3584                        fresh_value = self.balance(a, cached=False)
3585                        if debug:
3586                            print(
3587                                'account', a,
3588                                'cached_value', cached_value,
3589                                'fresh_value', fresh_value,
3590                                'a_balance', a_balance,
3591                            )
3592                        assert cached_value == a_balance
3593                        assert fresh_value == a_balance
3594
3595                        cached_value = self.balance(b, cached=True)
3596                        fresh_value = self.balance(b, cached=False)
3597                        if debug:
3598                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
3599                        assert cached_value == b_balance
3600                        assert fresh_value == b_balance
3601
3602            # Transfer all in many chunks randomly from B to A
3603            a_SAR_balance = 137125
3604            b_USD_balance = 50100
3605            b_USD_exchange = self.exchange(b_USD)
3606            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
3607            if debug:
3608                print('amounts', amounts)
3609            i = 0
3610            for x in amounts:
3611                if debug:
3612                    print(f'{i} - transfer-with-exchange({x})')
3613                self.transfer(
3614                    unscaled_amount=self.unscale(x),
3615                    from_account=b_USD,
3616                    to_account=a_SAR,
3617                    desc=f'{x} USD -> SAR',
3618                    debug=debug,
3619                )
3620
3621                b_USD_balance -= x
3622                cached_value = self.balance(b_USD, cached=True)
3623                fresh_value = self.balance(b_USD, cached=False)
3624                if debug:
3625                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3626                          b_USD_balance)
3627                assert cached_value == b_USD_balance
3628                assert fresh_value == b_USD_balance
3629
3630                a_SAR_balance += int(x * b_USD_exchange['rate'])
3631                cached_value = self.balance(a_SAR, cached=True)
3632                fresh_value = self.balance(a_SAR, cached=False)
3633                if debug:
3634                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3635                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
3636                assert cached_value == a_SAR_balance
3637                assert fresh_value == a_SAR_balance
3638                i += 1
3639
3640            # Transfer all in many chunks randomly from C to A
3641            c_SAR_balance = 37500
3642            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
3643            if debug:
3644                print('amounts', amounts)
3645            i = 0
3646            for x in amounts:
3647                if debug:
3648                    print(f'{i} - transfer-with-exchange({x})')
3649                self.transfer(
3650                    unscaled_amount=self.unscale(x),
3651                    from_account=c_SAR,
3652                    to_account=a_SAR,
3653                    desc=f'{x} SAR -> a_SAR',
3654                    debug=debug,
3655                )
3656
3657                c_SAR_balance -= x
3658                cached_value = self.balance(c_SAR, cached=True)
3659                fresh_value = self.balance(c_SAR, cached=False)
3660                if debug:
3661                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3662                          c_SAR_balance)
3663                assert cached_value == c_SAR_balance
3664                assert fresh_value == c_SAR_balance
3665
3666                a_SAR_balance += x
3667                cached_value = self.balance(a_SAR, cached=True)
3668                fresh_value = self.balance(a_SAR, cached=False)
3669                if debug:
3670                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3671                          a_SAR_balance)
3672                assert cached_value == a_SAR_balance
3673                assert fresh_value == a_SAR_balance
3674                i += 1
3675
3676            assert self.export_json('accounts-transfer-with-exchange-rates.json')
3677            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
3678
3679            # check & zakat with exchange rates for many cycles
3680
3681            lock = None
3682            for rate, values in {
3683                1: {
3684                    'in': [1000, 2000, 10000],
3685                    'exchanged': [100000, 200000, 1000000],
3686                    'out': [2500, 5000, 73140],
3687                },
3688                3.75: {
3689                    'in': [200, 1000, 5000],
3690                    'exchanged': [75000, 375000, 1875000],
3691                    'out': [1875, 9375, 137138],
3692                },
3693            }.items():
3694                a, b, c = values['in']
3695                m, n, o = values['exchanged']
3696                x, y, z = values['out']
3697                if debug:
3698                    print('rate', rate, 'values', values)
3699                for case in [
3700                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3701                        {'safe': {0: {'below_nisab': x}}},
3702                    ], False, m),
3703                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3704                        {'safe': {0: {'count': 1, 'total': y}}},
3705                    ], True, n),
3706                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
3707                        {'cave': {0: {'count': 3, 'total': z}}},
3708                    ], True, o),
3709                ]:
3710                    if debug:
3711                        print(f'############# check(rate: {rate}) #############')
3712                        print('case', case)
3713                    self.reset()
3714                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
3715                    self.track(
3716                        unscaled_value=case[0],
3717                        desc='test-check',
3718                        account=case[1],
3719                        logging=True,
3720                        created_time_ns=case[2],
3721                    )
3722                    assert self.snapshot()
3723
3724                    # assert self.nolock()
3725                    # history_size = len(self._vault['history'])
3726                    # print('history_size', history_size)
3727                    # assert history_size == 2
3728                    lock = self.lock()
3729                    assert lock
3730                    assert not self.nolock()
3731                    report = self.check(2.17, None, debug)
3732                    (valid, brief, plan) = report
3733                    if debug:
3734                        print('brief', brief)
3735                    assert valid == case[4]
3736                    assert case[5] == brief[0]
3737                    assert case[5] == brief[1]
3738
3739                    if debug:
3740                        pp().pprint(plan)
3741
3742                    for x in plan:
3743                        assert case[1] == x
3744                        if 'total' in case[3][0][x][0].keys():
3745                            assert case[3][0][x][0]['total'] == int(brief[2])
3746                            assert int(plan[x][0]['total']) == case[3][0][x][0]['total']
3747                            assert int(plan[x][0]['count']) == case[3][0][x][0]['count']
3748                        else:
3749                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3750                    if debug:
3751                        pp().pprint(report)
3752                    result = self.zakat(report, debug=debug)
3753                    if debug:
3754                        print('zakat-result', result, case[4])
3755                    assert result == case[4]
3756                    report = self.check(2.17, None, debug)
3757                    (valid, brief, plan) = report
3758                    assert valid is False
3759
3760            history_size = len(self._vault['history'])
3761            if debug:
3762                print('history_size', history_size)
3763            assert history_size == 3
3764            assert not self.nolock()
3765            assert self.recall(False, debug=debug) is False
3766            self.free(lock)
3767            assert self.nolock()
3768
3769            for i in range(3, 0, -1):
3770                history_size = len(self._vault['history'])
3771                if debug:
3772                    print('history_size', history_size)
3773                assert history_size == i
3774                assert self.recall(False, debug=debug) is True
3775
3776            assert self.nolock()
3777            assert self.recall(False, debug=debug) is False
3778
3779            history_size = len(self._vault['history'])
3780            if debug:
3781                print('history_size', history_size)
3782            assert history_size == 0
3783
3784            account_size = len(self._vault['account'])
3785            if debug:
3786                print('account_size', account_size)
3787            assert account_size == 0
3788
3789            report_size = len(self._vault['report'])
3790            if debug:
3791                print('report_size', report_size)
3792            assert report_size == 0
3793
3794            assert self.nolock()
3795
3796            # csv
3797
3798            csv_count = 1000
3799
3800            for with_rate, path in {
3801                False: 'test-import_csv-no-exchange',
3802                True: 'test-import_csv-with-exchange',
3803            }.items():
3804
3805                if debug:
3806                    print('test_import_csv', with_rate, path)
3807
3808                csv_path = path + '.csv'
3809                if os.path.exists(csv_path):
3810                    os.remove(csv_path)
3811                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
3812                if debug:
3813                    print('generate_random_csv_file', c)
3814                assert c == csv_count
3815                assert os.path.getsize(csv_path) > 0
3816                cache_path = self.import_csv_cache_path()
3817                if os.path.exists(cache_path):
3818                    os.remove(cache_path)
3819                self.reset()
3820                lock = self.lock()
3821                (created, found, bad) = self.import_csv(csv_path, debug)
3822                bad_count = len(bad)
3823                assert bad_count > 0
3824                if debug:
3825                    print(f'csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})')
3826                    print('bad', bad)
3827                tmp_size = os.path.getsize(cache_path)
3828                assert tmp_size > 0
3829
3830                # TODO: assert created + found + bad_count == csv_count
3831                # TODO: assert created == csv_count
3832                # TODO: assert bad_count == 0
3833                (created_2, found_2, bad_2) = self.import_csv(csv_path)
3834                bad_2_count = len(bad_2)
3835                if debug:
3836                    print(f'csv-imported: ({created_2}, {found_2}, {bad_2_count})')
3837                    print('bad', bad)
3838                assert bad_2_count > 0
3839                # TODO: assert tmp_size == os.path.getsize(cache_path)
3840                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
3841                # TODO: assert created == found_2
3842                # TODO: assert bad_count == bad_2_count
3843                # TODO: assert found_2 == csv_count
3844                # TODO: assert bad_2_count == 0
3845                # TODO: assert created_2 == 0
3846
3847                # payment parts
3848
3849                positive_parts = self.build_payment_parts(100, positive_only=True)
3850                assert self.check_payment_parts(positive_parts) != 0
3851                assert self.check_payment_parts(positive_parts) != 0
3852                all_parts = self.build_payment_parts(300, positive_only=False)
3853                assert self.check_payment_parts(all_parts) != 0
3854                assert self.check_payment_parts(all_parts) != 0
3855                if debug:
3856                    pp().pprint(positive_parts)
3857                    pp().pprint(all_parts)
3858                # dynamic discount
3859                suite = []
3860                count = 3
3861                for exceed in [False, True]:
3862                    case = []
3863                    for parts in [positive_parts, all_parts]:
3864                        part = parts.copy()
3865                        demand = part['demand']
3866                        if debug:
3867                            print(demand, part['total'])
3868                        i = 0
3869                        z = demand / count
3870                        cp = {
3871                            'account': {},
3872                            'demand': demand,
3873                            'exceed': exceed,
3874                            'total': part['total'],
3875                        }
3876                        j = ''
3877                        for x, y in part['account'].items():
3878                            x_exchange = self.exchange(x)
3879                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
3880                            if exceed and zz <= demand:
3881                                i += 1
3882                                y['part'] = zz
3883                                if debug:
3884                                    print(exceed, y)
3885                                cp['account'][x] = y
3886                                case.append(y)
3887                            elif not exceed and y['balance'] >= zz:
3888                                i += 1
3889                                y['part'] = zz
3890                                if debug:
3891                                    print(exceed, y)
3892                                cp['account'][x] = y
3893                                case.append(y)
3894                            j = x
3895                            if i >= count:
3896                                break
3897                        if len(cp['account'][j]) > 0:
3898                            suite.append(cp)
3899                if debug:
3900                    print('suite', len(suite))
3901                for case in suite:
3902                    if debug:
3903                        print('case', case)
3904                    result = self.check_payment_parts(case)
3905                    if debug:
3906                        print('check_payment_parts', result, f'exceed: {exceed}')
3907                    assert result == 0
3908
3909                    report = self.check(2.17, None, debug)
3910                    (valid, brief, plan) = report
3911                    if debug:
3912                        print('valid', valid)
3913                    zakat_result = self.zakat(report, parts=case, debug=debug)
3914                    if debug:
3915                        print('zakat-result', zakat_result)
3916                    assert valid == zakat_result
3917
3918                assert self.free(lock)
3919
3920            assert self.save(path + f'.{self.ext()}')
3921            assert self.export_json(path + '.json')
3922
3923            assert self.export_json('1000-transactions-test.json')
3924            assert self.save(f'1000-transactions-test.{self.ext()}')
3925            return True
3926        except Exception as e:
3927            # pp().pprint(self._vault)
3928            assert self.export_json('test-snapshot.json')
3929            assert self.save(f'test-snapshot.{self.ext()}')
3930            raise e

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 camel 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:

Data Structure:

The ZakatTracker class utilizes a nested dictionary structure called '_vault' to store and manage data.

_vault (dict):
    - account (dict):
        - {account_name} (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_name} (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_db/', history_mode: bool = True)
461    def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True):
462        """
463        Initialize ZakatTracker with database path and history mode.
464
465        Parameters:
466        - db_path (str, optional): The path to the database  directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
467        - history_mode (bool, optional): The mode for tracking history. Default is True.
468
469        Returns:
470        None
471        """
472        self.reset()
473        self._memory_mode = db_path == ':memory:'
474        self._history(history_mode)
475        if not self._memory_mode:
476            self.path(f'{db_path}/db.{self.ext()}')

Initialize ZakatTracker with database path and history mode.

Parameters:

  • db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
  • history_mode (bool, optional): The mode for tracking history. Default is True.

Returns: None

@staticmethod
def Version() -> str:
377    @staticmethod
378    def Version() -> str:
379        """
380        Returns the current version of the software.
381
382        This function returns a string representing the current version of the software,
383        including major, minor, and patch version numbers in the format 'X.Y.Z'.
384
385        Returns:
386        - str: The current version of the software.
387        """
388        return '0.2.99'

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:
390    @staticmethod
391    def ZakatCut(x: float) -> float:
392        """
393        Calculates the Zakat amount due on an asset.
394
395        This function calculates the zakat amount due on a given asset value over one lunar year.
396        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
397        that exceeds a certain threshold (Nisab).
398
399        Parameters:
400        - x (float): The total value of the asset on which Zakat is to be calculated.
401
402        Returns:
403        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
404        """
405        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 (float): The total value of the asset on which Zakat is to be calculated.

Returns:

  • float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
@staticmethod
def TimeCycle(days: int = 355) -> int:
407    @staticmethod
408    def TimeCycle(days: int = 355) -> int:
409        """
410        Calculates the approximate duration of a lunar year in nanoseconds.
411
412        This function calculates the approximate duration of a lunar year based on the given number of days.
413        It converts the given number of days into nanoseconds for use in high-precision timing applications.
414
415        Parameters:
416        - days (int, optional): The number of days in a lunar year. Defaults to 355,
417              which is an approximation of the average length of a lunar year.
418
419        Returns:
420        - int: The approximate duration of a lunar year in nanoseconds.
421        """
422        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 (int, optional): The number of days in a lunar year. Defaults to 355, which is an approximation of the average length of a lunar year.

Returns:

  • int: The approximate duration of a lunar year in nanoseconds.
@staticmethod
def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
424    @staticmethod
425    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
426        """
427        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
428
429        This function calculates the Nisab value, which is the minimum threshold of wealth,
430        that makes an individual liable for paying Zakat.
431        The Nisab value is determined by the equivalent value of a specific amount
432        of gold or silver (currently 595 grams in silver) in the local currency.
433
434        Parameters:
435        - gram_price (float): The price per gram of Nisab.
436        - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
437
438        Returns:
439        - float: The total value of Nisab based on the given price per gram.
440        """
441        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, optional): 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.
@staticmethod
def ext() -> str:
443    @staticmethod
444    def ext() -> str:
445        """
446        Returns the file extension used by the ZakatTracker class.
447
448        Parameters:
449        None
450
451        Returns:
452        - str: The file extension used by the ZakatTracker class, which is 'camel'.
453        """
454        return 'camel'

Returns the file extension used by the ZakatTracker class.

Parameters: None

Returns:

  • str: The file extension used by the ZakatTracker class, which is 'camel'.
def path(self, path: str = None) -> str:
478    def path(self, path: str = None) -> str:
479        """
480        Set or get the path to the database file.
481
482        If no path is provided, the current path is returned.
483        If a path is provided, it is set as the new path.
484        The function also creates the necessary directories if the provided path is a file.
485
486        Parameters:
487        - path (str, optional): The new path to the database file. If not provided, the current path is returned.
488
489        Returns:
490        - str: The current or new path to the database file.
491        """
492        if path is None:
493            return self._vault_path
494        self._vault_path = pathlib.Path(path).resolve()
495        base_path = pathlib.Path(path).resolve()
496        if base_path.is_file() or base_path.suffix:
497            base_path = base_path.parent
498        base_path.mkdir(parents=True, exist_ok=True)
499        self._base_path = base_path
500        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, optional): 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:
502    def base_path(self, *args) -> str:
503        """
504        Generate a base path by joining the provided arguments with the existing base path.
505
506        Parameters:
507        - *args (str): Variable length argument list of strings to be joined with the base path.
508
509        Returns:
510        - str: The generated base path. If no arguments are provided, the existing base path is returned.
511        """
512        if not args:
513            return str(self._base_path)
514        filtered_args = []
515        ignored_filename = None
516        for arg in args:
517            if pathlib.Path(arg).suffix:
518                ignored_filename = arg
519            else:
520                filtered_args.append(arg)
521        base_path = pathlib.Path(self._base_path)
522        full_path = base_path.joinpath(*filtered_args)
523        full_path.mkdir(parents=True, exist_ok=True)
524        if ignored_filename is not None:
525            return full_path.resolve() / ignored_filename  # Join with the ignored filename
526        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:
528    @staticmethod
529    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
530        """
531        Scales a numerical value by a specified power of 10, returning an integer.
532
533        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
534        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
535
536        Parameters:
537        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
538        - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
539            by a factor of 100 (e.g., converts 1.23 to 123).
540
541        Returns:
542        - The scaled value, rounded to the nearest integer.
543
544        Raises:
545        - TypeError: If the input `x` is not a valid numeric type.
546
547        Examples:
548        >>> ZakatTracker.scale(3.14159)
549        314
550        >>> ZakatTracker.scale(1234, decimal_places=3)
551        1234000
552        >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
553        50
554        """
555        if not isinstance(x, (float, int, decimal.Decimal)):
556            raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.')
557        return int(decimal.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.Decimal) and facilitate precise scaling operations, particularly useful in financial or scientific calculations.

Parameters:

  • x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
  • decimal_places (int, optional): 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.Decimal('0.005'), decimal_places=4)
50
@staticmethod
def unscale( x: int, return_type: type = <class 'float'>, decimal_places: int = 2) -> float | decimal.Decimal:
559    @staticmethod
560    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
561        """
562        Unscales an integer by a power of 10.
563
564        Parameters:
565        - x (int): The integer to unscale.
566        - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
567        - decimal_places (int, optional): The power of 10 to use. Defaults to 2.
568
569        Returns:
570        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
571
572        Raises:
573        - TypeError: If the return_type is not float or decimal.Decimal.
574        """
575        if return_type not in (float, decimal.Decimal):
576            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
577        return round(return_type(x / (10 ** decimal_places)), decimal_places)

Unscales an integer by a power of 10.

Parameters:

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

Returns:

  • float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.

Raises:

  • TypeError: If the return_type is not float or decimal.Decimal.
def reset(self) -> None:
579    def reset(self) -> None:
580        """
581        Reset the internal data structure to its initial state.
582
583        Parameters:
584        None
585
586        Returns:
587        None
588        """
589        self._vault = {
590            'account': {},
591            'exchange': {},
592            'history': {},
593            'lock': None,
594            'report': {},
595        }

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

@staticmethod
def minimum_time_diff_ns() -> tuple[int, int]:
600    @staticmethod
601    def minimum_time_diff_ns() -> tuple[int, int]:
602        """
603        Calculates the minimum time difference between two consecutive calls to
604        `ZakatTracker._time()` in nanoseconds.
605
606        This method is used internally to determine the minimum granularity of
607        time measurements within the system.
608
609        Returns:
610        - tuple[int, int]:
611            - The minimum time difference in nanoseconds.
612            - The number of iterations required to measure the difference.
613        """
614        i = 0
615        x = y = ZakatTracker._time()
616        while x == y:
617            y = ZakatTracker._time()
618            i += 1
619        return y - x, i

Calculates the minimum time difference between two consecutive calls to ZakatTracker._time() in nanoseconds.

This method is used internally to determine the minimum granularity of time measurements within the system.

Returns:

  • tuple[int, int]:
    • The minimum time difference in nanoseconds.
    • The number of iterations required to measure the difference.
@staticmethod
def time(now: datetime.datetime = None) -> int:
643    @staticmethod
644    def time(now: datetime.datetime = None) -> int:
645        """
646        Generates a unique, monotonically increasing timestamp based on the provided
647        datetime object or the current datetime.
648
649        This method ensures that timestamps are unique even if called in rapid succession
650        by introducing a small delay if necessary, based on the system's minimum
651        time resolution.
652
653        Parameters:
654        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
655        If not provided, the current datetime is used.
656
657        Returns:
658        - int: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
659        """
660        new_time = ZakatTracker._time(now)
661        if ZakatTracker._last_time_ns is None:
662            ZakatTracker._last_time_ns = new_time
663            return new_time
664        while new_time == ZakatTracker._last_time_ns:
665            if ZakatTracker._time_diff_ns is None:
666                diff, _ = ZakatTracker.minimum_time_diff_ns()
667                ZakatTracker._time_diff_ns = math.ceil(diff)
668            time.sleep(ZakatTracker._time_diff_ns / 1_000_000_000)
669            new_time = ZakatTracker._time()
670        ZakatTracker._last_time_ns = new_time
671        return new_time

Generates a unique, monotonically increasing timestamp based on the provided datetime object or the current datetime.

This method ensures that timestamps are unique even if called in rapid succession by introducing a small delay if necessary, based on the system's minimum time resolution.

Parameters:

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

Returns:

  • int: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
@staticmethod
def time_to_datetime(ordinal_ns: int) -> datetime.datetime:
673    @staticmethod
674    def time_to_datetime(ordinal_ns: int) -> datetime.datetime:
675        """
676        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
677        back to a datetime object.
678
679        Parameters:
680        - ordinal_ns (int): The timestamp in nanoseconds since the epoch (January 1, 1AD).
681
682        Returns:
683        - datetime.datetime: The corresponding datetime object.
684        """
685        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
686        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
687        return datetime.datetime.combine(d, datetime.time()) + t

Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) back to a datetime object.

Parameters:

  • ordinal_ns (int): The timestamp in nanoseconds since the epoch (January 1, 1AD).

Returns:

  • datetime.datetime: The corresponding datetime object.
def clean_history(self, lock: int = None) -> int:
689    def clean_history(self, lock: int = None) -> int:
690        """
691        Cleans up the empty history records of actions performed on the ZakatTracker instance.
692
693        Parameters:
694        - lock (int, optional): The lock ID is used to clean up the empty history.
695            If not provided, it cleans up the empty history records for all locks.
696
697        Returns:
698        - int: The number of locks cleaned up.
699        """
700        count = 0
701        if lock in self._vault['history']:
702            if len(self._vault['history'][lock]) <= 0:
703                count += 1
704                del self._vault['history'][lock]
705            return count
706        self.free(self.lock())
707        for key in self._vault['history']:
708            if len(self._vault['history'][key]) <= 0:
709                count += 1
710                del self._vault['history'][key]
711        return count

Cleans up the empty history records 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:
780    def nolock(self) -> bool:
781        """
782        Check if the vault lock is currently not set.
783
784        Parameters:
785        None
786
787        Returns:
788        - bool: True if the vault lock is not set, False otherwise.
789        """
790        return self._vault['lock'] is None

Check if the vault lock is currently not set.

Parameters: None

Returns:

  • bool: True if the vault lock is not set, False otherwise.
def lock(self) -> int:
805    def lock(self) -> int:
806        """
807        Acquires a lock on the ZakatTracker instance.
808
809        Parameters:
810        None
811
812        Returns:
813        - int: The lock ID. This ID can be used to release the lock later.
814        """
815        return self._step()

Acquires a lock on the ZakatTracker instance.

Parameters: None

Returns:

  • int: The lock ID. This ID can be used to release the lock later.
def steps(self) -> dict:
817    def steps(self) -> dict:
818        """
819        Returns a copy of the history of steps taken in the ZakatTracker.
820
821        The history is a dictionary where each key is a unique identifier for a step,
822        and the corresponding value is a dictionary containing information about the step.
823
824        Parameters:
825        None
826
827        Returns:
828        - dict: A copy of the history of steps taken in the ZakatTracker.
829        """
830        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.

Parameters: None

Returns:

  • dict: A copy of the history of steps taken in the ZakatTracker.
def free(self, lock: int, auto_save: bool = True) -> bool:
832    def free(self, lock: int, auto_save: bool = True) -> bool:
833        """
834        Releases the lock on the database.
835
836        Parameters:
837        - lock (int): The lock ID to be released.
838        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
839
840        Returns:
841        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
842        """
843        if lock == self._vault['lock']:
844            self.clean_history(lock)
845            self._vault['lock'] = None
846            if auto_save and not self._memory_mode:
847                return self.save(self.path())
848            return True
849        return False

Releases the lock on the database.

Parameters:

  • lock (int): The lock ID to be released.
  • auto_save (bool, optional): 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 recall(self, dry: bool = True, lock: int = None, debug: bool = False) -> bool:
 851    def recall(self, dry: bool = True, lock: int = None, debug: bool = False) -> bool:
 852        """
 853        Revert the last operation.
 854
 855        Parameters:
 856        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
 857        - lock (int, optional): An optional lock value to ensure the recall
 858                operation is performed on the expected history entry. If provided,
 859                it checks if the current lock and the most recent history key
 860                match the given lock value. Defaults to None.
 861        - debug (bool, optional): If True, the function will print debug information. Default is False.
 862
 863        Returns:
 864        - bool: True if the operation was successful, False otherwise.
 865        """
 866        if not self.nolock() or len(self._vault['history']) == 0:
 867            return False
 868        if len(self._vault['history']) <= 0:
 869            return False
 870        ref = sorted(self._vault['history'].keys())[-1]
 871        if debug:
 872            print('recall', ref)
 873        memory = self._vault['history'][ref]
 874        if debug:
 875            print(type(memory), 'memory', memory)
 876        if lock is not None:
 877            assert self._vault['lock'] == lock, "Invalid current lock"
 878            assert ref == lock, "Invalid last lock"
 879            assert self._history(), "History mode should be enabled, found off!!!"
 880        limit = len(memory) + 1
 881        sub_positive_log_negative = 0
 882        for i in range(-1, -limit, -1):
 883            x = memory[i]
 884            if debug:
 885                print(type(x), x)
 886            match x['action']:
 887                case Action.CREATE:
 888                    if x['account'] is not None:
 889                        if self.account_exists(x['account']):
 890                            if debug:
 891                                print('account', self._vault['account'][x['account']])
 892                            assert len(self._vault['account'][x['account']]['box']) == 0
 893                            assert len(self._vault['account'][x['account']]['log']) == 0
 894                            assert self._vault['account'][x['account']]['balance'] == 0
 895                            assert self._vault['account'][x['account']]['count'] == 0
 896                            if dry:
 897                                continue
 898                            del self._vault['account'][x['account']]
 899
 900                case Action.TRACK:
 901                    if x['account'] is not None:
 902                        if self.account_exists(x['account']):
 903                            if dry:
 904                                continue
 905                            self._vault['account'][x['account']]['balance'] -= x['value']
 906                            self._vault['account'][x['account']]['count'] -= 1
 907                            del self._vault['account'][x['account']]['box'][x['ref']]
 908
 909                case Action.LOG:
 910                    if x['account'] is not None:
 911                        if self.account_exists(x['account']):
 912                            if x['ref'] in self._vault['account'][x['account']]['log']:
 913                                if dry:
 914                                    continue
 915                                if sub_positive_log_negative == -x['value']:
 916                                    self._vault['account'][x['account']]['count'] -= 1
 917                                    sub_positive_log_negative = 0
 918                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
 919                                if not box_ref is None:
 920                                    assert self.box_exists(x['account'], box_ref)
 921                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
 922                                    assert box_value < 0
 923
 924                                    try:
 925                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
 926                                    except TypeError:
 927                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += decimal.Decimal(
 928                                            -box_value)
 929
 930                                    try:
 931                                        self._vault['account'][x['account']]['balance'] += -box_value
 932                                    except TypeError:
 933                                        self._vault['account'][x['account']]['balance'] += decimal.Decimal(-box_value)
 934
 935                                    self._vault['account'][x['account']]['count'] -= 1
 936                                del self._vault['account'][x['account']]['log'][x['ref']]
 937
 938                case Action.SUB:
 939                    if x['account'] is not None:
 940                        if self.account_exists(x['account']):
 941                            if x['ref'] in self._vault['account'][x['account']]['box']:
 942                                if dry:
 943                                    continue
 944                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
 945                                self._vault['account'][x['account']]['balance'] += x['value']
 946                                sub_positive_log_negative = x['value']
 947
 948                case Action.ADD_FILE:
 949                    if x['account'] is not None:
 950                        if self.account_exists(x['account']):
 951                            if x['ref'] in self._vault['account'][x['account']]['log']:
 952                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
 953                                    if dry:
 954                                        continue
 955                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
 956
 957                case Action.REMOVE_FILE:
 958                    if x['account'] is not None:
 959                        if self.account_exists(x['account']):
 960                            if x['ref'] in self._vault['account'][x['account']]['log']:
 961                                if dry:
 962                                    continue
 963                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
 964
 965                case Action.BOX_TRANSFER:
 966                    if x['account'] is not None:
 967                        if self.account_exists(x['account']):
 968                            if x['ref'] in self._vault['account'][x['account']]['box']:
 969                                if dry:
 970                                    continue
 971                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
 972
 973                case Action.EXCHANGE:
 974                    if x['account'] is not None:
 975                        if x['account'] in self._vault['exchange']:
 976                            if x['ref'] in self._vault['exchange'][x['account']]:
 977                                if dry:
 978                                    continue
 979                                del self._vault['exchange'][x['account']][x['ref']]
 980
 981                case Action.REPORT:
 982                    if x['ref'] in self._vault['report']:
 983                        if dry:
 984                            continue
 985                        del self._vault['report'][x['ref']]
 986
 987                case Action.ZAKAT:
 988                    if x['account'] is not None:
 989                        if self.account_exists(x['account']):
 990                            if x['ref'] in self._vault['account'][x['account']]['box']:
 991                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
 992                                    if dry:
 993                                        continue
 994                                    match x['math']:
 995                                        case MathOperation.ADDITION:
 996                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
 997                                                'value']
 998                                        case MathOperation.EQUAL:
 999                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
1000                                        case MathOperation.SUBTRACTION:
1001                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
1002                                                'value']
1003
1004        if not dry:
1005            del self._vault['history'][ref]
1006        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.
  • lock (int, optional): An optional lock value to ensure the recall operation is performed on the expected history entry. If provided, it checks if the current lock and the most recent history key match the given lock value. Defaults to None.
  • debug (bool, optional): If True, the function will print debug information. Default is False.

Returns:

  • bool: True if the operation was successful, False otherwise.
def vault(self) -> dict:
1008    def vault(self) -> dict:
1009        """
1010        Returns a copy of the internal vault dictionary.
1011
1012        This method is used to retrieve the current state of the ZakatTracker object.
1013        It provides a snapshot of the internal data structure, allowing for further
1014        processing or analysis.
1015
1016        Parameters:
1017        None
1018
1019        Returns:
1020        - dict: A copy of the internal vault dictionary.
1021        """
1022        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.

Parameters: None

Returns:

  • dict: A copy of the internal vault dictionary.
@staticmethod
def stats_init() -> dict[str, tuple[int, str]]:
1024    @staticmethod
1025    def stats_init() -> dict[str, tuple[int, str]]:
1026        """
1027        Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.
1028
1029        The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:
1030        - The initial size of the respective statistic in bytes (int).
1031        - The initial size of the respective statistic in a human-readable format (str).
1032
1033        Parameters:
1034        None
1035
1036        Returns:
1037        - dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
1038        """
1039        return {
1040            'database': (0, '0'),
1041            'ram': (0, '0'),
1042        }

Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.

The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:

  • The initial size of the respective statistic in bytes (int).
  • The initial size of the respective statistic in a human-readable format (str).

Parameters: None

Returns:

  • dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
def stats(self, ignore_ram: bool = True) -> dict[str, tuple[float, str]]:
1044    def stats(self, ignore_ram: bool = True) -> dict[str, tuple[float, str]]:
1045        """
1046        Calculates and returns statistics about the object's data storage.
1047
1048        This method determines the size of the database file on disk and the
1049        size of the data currently held in RAM (likely within a dictionary).
1050        Both sizes are reported in bytes and in a human-readable format
1051        (e.g., KB, MB).
1052
1053        Parameters:
1054        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
1055
1056        Returns:
1057        - dict[str, tuple[float, str]]: A dictionary containing the following statistics:
1058
1059            * 'database': A tuple with two elements:
1060                - The database file size in bytes (float).
1061                - The database file size in human-readable format (str).
1062            * 'ram': A tuple with two elements:
1063                - The RAM usage (dictionary size) in bytes (float).
1064                - The RAM usage in human-readable format (str).
1065
1066        Example:
1067        >>> x = ZakatTracker()
1068        >>> stats = x.stats()
1069        >>> print(stats['database'])
1070        (256000, '250.0 KB')
1071        >>> print(stats['ram'])
1072        (12345, '12.1 KB')
1073        """
1074        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
1075        file_size = os.path.getsize(self.path())
1076        return {
1077            'database': (file_size, self.human_readable_size(file_size)),
1078            'ram': (ram_size, self.human_readable_size(ram_size)),
1079        }

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).

Parameters:

  • ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True

Returns:

  • dict[str, tuple[float, str]]: A dictionary containing the following statistics:
* 'database': A tuple with two elements:
    - The database file size in bytes (float).
    - The database file size in human-readable format (str).
* 'ram': A tuple with two elements:
    - The RAM usage (dictionary size) in bytes (float).
    - The RAM usage in human-readable format (str).

Example:

>>> x = ZakatTracker()
>>> stats = x.stats()
>>> print(stats['database'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
def files(self) -> list[dict[str, str | int]]:
1081    def files(self) -> list[dict[str, str | int]]:
1082        """
1083        Retrieves information about files associated with this class.
1084
1085        This class method provides a standardized way to gather details about
1086        files used by the class for storage, snapshots, and CSV imports.
1087
1088        Parameters:
1089        None
1090
1091        Returns:
1092        - list[dict[str, str | int]]: A list of dictionaries, each containing information
1093            about a specific file:
1094
1095            * type (str): The type of file ('database', 'snapshot', 'import_csv').
1096            * path (str): The full file path.
1097            * exists (bool): Whether the file exists on the filesystem.
1098            * size (int): The file size in bytes (0 if the file doesn't exist).
1099            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
1100
1101        Example:
1102        ```
1103        file_info = MyClass.files()
1104        for info in file_info:
1105            print(f'Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}')
1106        ```
1107        """
1108        result = []
1109        for file_type, path in {
1110            'database': self.path(),
1111            'snapshot': self.snapshot_cache_path(),
1112            'import_csv': self.import_csv_cache_path(),
1113        }.items():
1114            exists = os.path.exists(path)
1115            size = os.path.getsize(path) if exists else 0
1116            human_readable_size = self.human_readable_size(size) if exists else 0
1117            result.append({
1118                'type': file_type,
1119                'path': path,
1120                'exists': exists,
1121                'size': size,
1122                'human_readable_size': human_readable_size,
1123            })
1124        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.

Parameters: None

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 account_exists(self, account) -> bool:
1126    def account_exists(self, account) -> bool:
1127        """
1128        Check if the given account exists in the vault.
1129
1130        Parameters:
1131        - account (str): The account number to check.
1132
1133        Returns:
1134        - bool: True if the account exists, False otherwise.
1135        """
1136        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:
1138    def box_size(self, account) -> int:
1139        """
1140        Calculate the size of the box for a specific account.
1141
1142        Parameters:
1143        - account (str): The account number for which the box size needs to be calculated.
1144
1145        Returns:
1146        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
1147        """
1148        if self.account_exists(account):
1149            return len(self._vault['account'][account]['box'])
1150        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:
1152    def log_size(self, account) -> int:
1153        """
1154        Get the size of the log for a specific account.
1155
1156        Parameters:
1157        - account (str): The account number for which the log size needs to be calculated.
1158
1159        Returns:
1160        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
1161        """
1162        if self.account_exists(account):
1163            return len(self._vault['account'][account]['log'])
1164        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 hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
1166    @staticmethod
1167    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
1168        """
1169        Calculates the hash of given byte data using the specified algorithm.
1170
1171        Parameters:
1172        - data (bytes): The byte data to hash.
1173        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
1174
1175        Returns:
1176        - str: The hexadecimal representation of the data's hash.
1177        """
1178        hash_obj = hashlib.new(algorithm)
1179        hash_obj.update(data)
1180        return hash_obj.hexdigest()

Calculates the hash of given byte data using the specified algorithm.

Parameters:

  • data (bytes): The byte data to hash.
  • algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.

Returns:

  • str: The hexadecimal representation of the data's hash.
@staticmethod
def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
1182    @staticmethod
1183    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
1184        """
1185        Calculates the hash of a file using the specified algorithm.
1186
1187        Parameters:
1188        - file_path (str): The path to the file.
1189        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
1190
1191        Returns:
1192        - str: The hexadecimal representation of the file's hash.
1193        """
1194        hash_obj = hashlib.new(algorithm)  # Create the hash object
1195        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
1196            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
1197                hash_obj.update(chunk)
1198        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):
1200    def snapshot_cache_path(self):
1201        """
1202        Generate the path for the cache file used to store snapshots.
1203
1204        The cache file is a camel file that stores the timestamps of the snapshots.
1205        The file name is derived from the main database file name by replacing the '.camel' extension with '.snapshots.camel'.
1206
1207        Parameters:
1208        None
1209
1210        Returns:
1211        - str: The path to the cache file.
1212        """
1213        path = str(self.path())
1214        ext = self.ext()
1215        ext_len = len(ext)
1216        if path.endswith(f'.{ext}'):
1217            path = path[:-ext_len - 1]
1218        _, filename = os.path.split(path + f'.snapshots.{ext}')
1219        return self.base_path(filename)

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

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

Parameters: None

Returns:

  • str: The path to the cache file.
def snapshot(self) -> bool:
1221    def snapshot(self) -> bool:
1222        """
1223        This function creates a snapshot of the current database state.
1224
1225        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
1226        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
1227        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
1228        in a new camel 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.
1229
1230        Parameters:
1231        None
1232
1233        Returns:
1234        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
1235        """
1236        current_hash = self.hash_file(self.path())
1237        cache: dict[str, int] = {}  # hash: time_ns
1238        try:
1239            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
1240                cache = camel.load(stream.read())
1241        except:
1242            pass
1243        if current_hash in cache:
1244            return True
1245        ref = time.time_ns()
1246        cache[current_hash] = ref
1247        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
1248            return False
1249        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
1250            stream.write(camel.dump(cache))
1251        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 camel 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]]:
1253    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
1254            -> dict[int, tuple[str, str, bool]]:
1255        """
1256        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
1257
1258        Parameters:
1259        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
1260        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
1261
1262        Returns:
1263        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
1264        and the values are tuples containing the snapshot's hash, path, and existence status.
1265        """
1266        cache: dict[str, int] = {}  # hash: time_ns
1267        try:
1268            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
1269                cache = camel.load(stream.read())
1270        except:
1271            pass
1272        if not cache:
1273            return {}
1274        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
1275        for hash_file, ref in cache.items():
1276            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
1277            exists = os.path.exists(path)
1278            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
1279            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
1280                continue
1281            if exists or not hide_missing:
1282                result[ref] = (hash_file, path, exists)
1283        return result

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

Parameters:

  • hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
  • verified_hash_only (bool, optional): 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 ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
1285    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
1286        """
1287        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
1288
1289        Parameters:
1290        - account (str): The account number for which to check the existence of the reference.
1291        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
1292        - ref (int): The reference (transaction) number to check for existence.
1293
1294        Returns:
1295        - bool: True if the reference exists for the given account and reference type, False otherwise.
1296        """
1297        if account in self._vault['account']:
1298            return ref in self._vault['account'][account][ref_type]
1299        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:
1301    def box_exists(self, account: str, ref: int) -> bool:
1302        """
1303        Check if a specific box (transaction) exists in the vault for a given account and reference.
1304
1305        Parameters:
1306        - account (str): The account number for which to check the existence of the box.
1307        - ref (int): The reference (transaction) number to check for existence.
1308
1309        Returns:
1310        - bool: True if the box exists for the given account and reference, False otherwise.
1311        """
1312        return self.ref_exists(account, 'box', ref)

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

Parameters:

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

Returns:

  • bool: True if the box exists for the given account and reference, False otherwise.
def track( self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: str = 1, logging: bool = True, created_time_ns: int = None, debug: bool = False) -> int:
1314    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: str = 1,
1315              logging: bool = True,
1316              created_time_ns: int = None,
1317              debug: bool = False) -> int:
1318        """
1319        This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
1320
1321        Parameters:
1322        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
1323        - desc (str, optional): The description of the transaction. Default is an empty string.
1324        - account (str, optional): The account for which the transaction is being tracked. Default is '1'.
1325        - logging (bool, optional): Whether to log the transaction. Default is True.
1326        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
1327        - debug (bool, optional): Whether to print debug information. Default is False.
1328
1329        Returns:
1330        - int: The timestamp of the transaction in nanoseconds since epoch(1AD).
1331
1332        Raises:
1333        - ValueError: The created_time_ns should be greater than zero.
1334        - ValueError: The log transaction happened again in the same nanosecond time.
1335        - ValueError: The box transaction happened again in the same nanosecond time.
1336        """
1337        if debug:
1338            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
1339        if created_time_ns is None:
1340            created_time_ns = self.time()
1341        if created_time_ns <= 0:
1342            raise ValueError('The created should be greater than zero.')
1343        no_lock = self.nolock()
1344        lock = self._lock()
1345        if not self.account_exists(account):
1346            if debug:
1347                print(f'account {account} created')
1348            self._vault['account'][account] = {
1349                'balance': 0,
1350                'box': {},
1351                'count': 0,
1352                'log': {},
1353                'hide': False,
1354                'zakatable': True,
1355                'created': created_time_ns,
1356            }
1357            self._step(Action.CREATE, account)
1358        if unscaled_value == 0:
1359            if no_lock:
1360                self.free(lock)
1361            return 0
1362        value = self.scale(unscaled_value)
1363        if logging:
1364            self._log(value=value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
1365        if debug:
1366            print('create-box', created_time_ns)
1367        if self.box_exists(account, created_time_ns):
1368            raise ValueError(f'The box transaction happened again in the same nanosecond time({created_time_ns}).')
1369        if debug:
1370            print('created-box', created_time_ns)
1371        self._vault['account'][account]['box'][created_time_ns] = {
1372            'capital': value,
1373            'count': 0,
1374            'last': 0,
1375            'rest': value,
1376            'total': 0,
1377        }
1378        self._step(Action.TRACK, account, ref=created_time_ns, value=value)
1379        if no_lock:
1380            self.free(lock)
1381        return created_time_ns

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

Parameters:

  • unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
  • desc (str, optional): The description of the transaction. Default is an empty string.
  • account (str, optional): The account for which the transaction is being tracked. Default is '1'.
  • logging (bool, optional): Whether to log the transaction. Default is True.
  • created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • int: The timestamp of the transaction in nanoseconds since epoch(1AD).

Raises:

  • ValueError: The created_time_ns should be greater than zero.
  • 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:
1383    def log_exists(self, account: str, ref: int) -> bool:
1384        """
1385        Checks if a specific transaction log entry exists for a given account.
1386
1387        Parameters:
1388        - account (str): The account number associated with the transaction log.
1389        - ref (int): The reference to the transaction log entry.
1390
1391        Returns:
1392        - bool: True if the transaction log entry exists, False otherwise.
1393        """
1394        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_time_ns: int = None, rate: float = None, description: str = None, debug: bool = False) -> dict:
1444    def exchange(self, account, created_time_ns: int = None, rate: float = None, description: str = None,
1445                 debug: bool = False) -> dict:
1446        """
1447        This method is used to record or retrieve exchange rates for a specific account.
1448
1449        Parameters:
1450        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1451        - created_time_ns (int, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1452        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1453        - description (str, optional): A description of the exchange rate.
1454        - debug (bool, optional): Whether to print debug information. Default is False.
1455
1456        Returns:
1457        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1458        it returns a dictionary with default values for the rate and description.
1459
1460        Raises:
1461        - ValueError: The created should be greater than zero.
1462        """
1463        if debug:
1464            print('exchange', f'debug={debug}')
1465        if created_time_ns is None:
1466            created_time_ns = self.time()
1467        if created_time_ns <= 0:
1468            raise ValueError('The created should be greater than zero.')
1469        if rate is not None:
1470            if rate <= 0:
1471                return {}
1472            if account not in self._vault['exchange']:
1473                self._vault['exchange'][account] = {}
1474            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1475                return {'time': created_time_ns, 'rate': 1, 'description': None}
1476            no_lock = self.nolock()
1477            lock = self._lock()
1478            self._vault['exchange'][account][created_time_ns] = {'rate': rate, 'description': description}
1479            self._step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
1480            if no_lock:
1481                self.free(lock)
1482            if debug:
1483                print('exchange-created-1',
1484                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
1485
1486        if account in self._vault['exchange']:
1487            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created_time_ns]
1488            if valid_rates:
1489                latest_rate = max(valid_rates, key=lambda x: x[0])
1490                if debug:
1491                    print('exchange-read-1',
1492                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
1493                          'latest_rate', latest_rate)
1494                result = latest_rate[1]
1495                result['time'] = latest_rate[0]
1496                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1497        if debug:
1498            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
1499        return {'time': created_time_ns, '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_time_ns (int, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
  • rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
  • description (str, optional): A description of the exchange rate.
  • debug (bool, optional): Whether to print debug information. Default is False.

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.

Raises:

  • ValueError: The created should be greater than zero.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1501    @staticmethod
1502    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1503        """
1504        This function calculates the exchanged amount of a currency.
1505
1506        Parameters:
1507        - x (float): The original amount of the currency.
1508        - x_rate (float): The exchange rate of the original currency.
1509        - y_rate (float): The exchange rate of the target currency.
1510
1511        Returns:
1512        - float: The exchanged amount of the target currency.
1513        """
1514        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

Parameters:

  • 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:
1516    def exchanges(self) -> dict:
1517        """
1518        Retrieve the recorded exchange rates for all accounts.
1519
1520        Parameters:
1521        None
1522
1523        Returns:
1524        - dict: A dictionary containing all recorded exchange rates.
1525        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1526        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1527        """
1528        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:
1530    def accounts(self) -> dict:
1531        """
1532        Returns a dictionary containing account numbers as keys and their respective balances as values.
1533
1534        Parameters:
1535        None
1536
1537        Returns:
1538        - dict: A dictionary where keys are account numbers and values are their respective balances.
1539        """
1540        result = {}
1541        for i in self._vault['account']:
1542            result[i] = self._vault['account'][i]['balance']
1543        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:
1545    def boxes(self, account) -> dict:
1546        """
1547        Retrieve the boxes (transactions) associated with a specific account.
1548
1549        Parameters:
1550        - account (str): The account number for which to retrieve the boxes.
1551
1552        Returns:
1553        - dict: A dictionary containing the boxes associated with the given account.
1554        If the account does not exist, an empty dictionary is returned.
1555        """
1556        if self.account_exists(account):
1557            return self._vault['account'][account]['box']
1558        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:
1560    def logs(self, account) -> dict:
1561        """
1562        Retrieve the logs (transactions) associated with a specific account.
1563
1564        Parameters:
1565        - account (str): The account number for which to retrieve the logs.
1566
1567        Returns:
1568        - dict: A dictionary containing the logs associated with the given account.
1569        If the account does not exist, an empty dictionary is returned.
1570        """
1571        if self.account_exists(account):
1572            return self._vault['account'][account]['log']
1573        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.
@staticmethod
def daily_logs_init() -> dict[str, dict]:
1575    @staticmethod
1576    def daily_logs_init() -> dict[str, dict]:
1577        """
1578        Initialize a dictionary to store daily, weekly, monthly, and yearly logs.
1579
1580        Parameters:
1581        None
1582
1583        Returns:
1584        - dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary.
1585            Later each key maps to another dictionary, which will store the logs for the corresponding time period.
1586        """
1587        return {
1588            'daily': {},
1589            'weekly': {},
1590            'monthly': {},
1591            'yearly': {},
1592        }

Initialize a dictionary to store daily, weekly, monthly, and yearly logs.

Parameters: None

Returns:

  • dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary. Later each key maps to another dictionary, which will store the logs for the corresponding time period.
def daily_logs( self, weekday: WeekDay = <WeekDay.FRIDAY: 4>, debug: bool = False):
1594    def daily_logs(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False):
1595        """
1596        Retrieve the daily logs (transactions) from all accounts.
1597
1598        The function groups the logs by day, month, and year, and calculates the total value for each group.
1599        It returns a dictionary where the keys are the timestamps of the daily groups,
1600        and the values are dictionaries containing the total value and the logs for that group.
1601
1602        Parameters:
1603        - weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday.
1604        - debug (bool, optional): Whether to print debug information. Default is False.
1605
1606        Returns:
1607        - dict: A dictionary containing the daily logs.
1608
1609        Example:
1610        >>> tracker = ZakatTracker()
1611        >>> tracker.sub(51, 'desc', 'account1')
1612        >>> ref = tracker.track(100, 'desc', 'account2')
1613        >>> tracker.add_file('account2', ref, 'file_0')
1614        >>> tracker.add_file('account2', ref, 'file_1')
1615        >>> tracker.add_file('account2', ref, 'file_2')
1616        >>> tracker.daily_logs()
1617        {
1618            'daily': {
1619                '2024-06-30': {
1620                    'positive': 100,
1621                    'negative': 51,
1622                    'total': 99,
1623                    'rows': [
1624                        {
1625                            'account': 'account1',
1626                            'desc': 'desc',
1627                            'file': {},
1628                            'ref': None,
1629                            'value': -51,
1630                            'time': 1690977015000000000,
1631                            'transfer': False,
1632                        },
1633                        {
1634                            'account': 'account2',
1635                            'desc': 'desc',
1636                            'file': {
1637                                1722919011626770944: 'file_0',
1638                                1722919011626812928: 'file_1',
1639                                1722919011626846976: 'file_2',
1640                            },
1641                            'ref': None,
1642                            'value': 100,
1643                            'time': 1690977015000000000,
1644                            'transfer': False,
1645                        },
1646                    ],
1647                },
1648            },
1649            'weekly': {
1650                datetime: {
1651                    'positive': 100,
1652                    'negative': 51,
1653                    'total': 99,
1654                },
1655            },
1656            'monthly': {
1657                '2024-06': {
1658                    'positive': 100,
1659                    'negative': 51,
1660                    'total': 99,
1661                },
1662            },
1663            'yearly': {
1664                2024: {
1665                    'positive': 100,
1666                    'negative': 51,
1667                    'total': 99,
1668                },
1669            },
1670        }
1671        """
1672        logs = {}
1673        for account in self.accounts():
1674            for k, v in self.logs(account).items():
1675                v['time'] = k
1676                v['account'] = account
1677                if k not in logs:
1678                    logs[k] = []
1679                logs[k].append(v)
1680        if debug:
1681            print('logs', logs)
1682        y = self.daily_logs_init()
1683        for i in sorted(logs, reverse=True):
1684            dt = self.time_to_datetime(i)
1685            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
1686            weekly = dt - datetime.timedelta(days=weekday.value)
1687            monthly = f'{dt.year}-{dt.month:02d}'
1688            yearly = dt.year
1689            # daily
1690            if daily not in y['daily']:
1691                y['daily'][daily] = {
1692                    'positive': 0,
1693                    'negative': 0,
1694                    'total': 0,
1695                    'rows': [],
1696                }
1697            transfer = len(logs[i]) > 1
1698            if debug:
1699                print('logs[i]', logs[i])
1700            for z in logs[i]:
1701                if debug:
1702                    print('z', z)
1703                # daily
1704                value = z['value']
1705                if value > 0:
1706                    y['daily'][daily]['positive'] += value
1707                else:
1708                    y['daily'][daily]['negative'] += -value
1709                y['daily'][daily]['total'] += value
1710                z['transfer'] = transfer
1711                y['daily'][daily]['rows'].append(z)
1712                # weekly
1713                if weekly not in y['weekly']:
1714                    y['weekly'][weekly] = {
1715                        'positive': 0,
1716                        'negative': 0,
1717                        'total': 0,
1718                    }
1719                if value > 0:
1720                    y['weekly'][weekly]['positive'] += value
1721                else:
1722                    y['weekly'][weekly]['negative'] += -value
1723                y['weekly'][weekly]['total'] += value
1724                # monthly
1725                if monthly not in y['monthly']:
1726                    y['monthly'][monthly] = {
1727                        'positive': 0,
1728                        'negative': 0,
1729                        'total': 0,
1730                    }
1731                if value > 0:
1732                    y['monthly'][monthly]['positive'] += value
1733                else:
1734                    y['monthly'][monthly]['negative'] += -value
1735                y['monthly'][monthly]['total'] += value
1736                # yearly
1737                if yearly not in y['yearly']:
1738                    y['yearly'][yearly] = {
1739                        'positive': 0,
1740                        'negative': 0,
1741                        'total': 0,
1742                    }
1743                if value > 0:
1744                    y['yearly'][yearly]['positive'] += value
1745                else:
1746                    y['yearly'][yearly]['negative'] += -value
1747                y['yearly'][yearly]['total'] += value
1748        if debug:
1749            print('y', y)
1750        return y

Retrieve the daily logs (transactions) from all accounts.

The function groups the logs by day, month, and year, and calculates the total value for each group. It returns a dictionary where the keys are the timestamps of the daily groups, and the values are dictionaries containing the total value and the logs for that group.

Parameters:

  • weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday.
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • dict: A dictionary containing the daily logs.

Example:

>>> tracker = ZakatTracker()
>>> tracker.sub(51, 'desc', 'account1')
>>> ref = tracker.track(100, 'desc', 'account2')
>>> tracker.add_file('account2', ref, 'file_0')
>>> tracker.add_file('account2', ref, 'file_1')
>>> tracker.add_file('account2', ref, 'file_2')
>>> tracker.daily_logs()
{
    'daily': {
        '2024-06-30': {
            'positive': 100,
            'negative': 51,
            'total': 99,
            'rows': [
                {
                    'account': 'account1',
                    'desc': 'desc',
                    'file': {},
                    'ref': None,
                    'value': -51,
                    'time': 1690977015000000000,
                    'transfer': False,
                },
                {
                    'account': 'account2',
                    'desc': 'desc',
                    'file': {
                        1722919011626770944: 'file_0',
                        1722919011626812928: 'file_1',
                        1722919011626846976: 'file_2',
                    },
                    'ref': None,
                    'value': 100,
                    'time': 1690977015000000000,
                    'transfer': False,
                },
            ],
        },
    },
    'weekly': {
        datetime: {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
    'monthly': {
        '2024-06': {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
    'yearly': {
        2024: {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
}
def add_file(self, account: str, ref: int, path: str) -> int:
1752    def add_file(self, account: str, ref: int, path: str) -> int:
1753        """
1754        Adds a file reference to a specific transaction log entry in the vault.
1755
1756        Parameters:
1757        - account (str): The account number associated with the transaction log.
1758        - ref (int): The reference to the transaction log entry.
1759        - path (str): The path of the file to be added.
1760
1761        Returns:
1762        - int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1763        """
1764        if self.account_exists(account):
1765            if ref in self._vault['account'][account]['log']:
1766                no_lock = self.nolock()
1767                lock = self._lock()
1768                file_ref = self.time()
1769                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1770                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1771                if no_lock:
1772                    self.free(lock)
1773                return file_ref
1774        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:
1776    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1777        """
1778        Removes a file reference from a specific transaction log entry in the vault.
1779
1780        Parameters:
1781        - account (str): The account number associated with the transaction log.
1782        - ref (int): The reference to the transaction log entry.
1783        - file_ref (int): The reference of the file to be removed.
1784
1785        Returns:
1786        - bool: True if the file reference is successfully removed, False otherwise.
1787        """
1788        if self.account_exists(account):
1789            if ref in self._vault['account'][account]['log']:
1790                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1791                    no_lock = self.nolock()
1792                    lock = self._lock()
1793                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1794                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1795                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1796                    if no_lock:
1797                        self.free(lock)
1798                    return True
1799        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:
1801    def balance(self, account: str = 1, cached: bool = True) -> int:
1802        """
1803        Calculate and return the balance of a specific account.
1804
1805        Parameters:
1806        - account (str, optional): The account number. Default is '1'.
1807        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1808
1809        Returns:
1810        - int: The balance of the account.
1811
1812        Notes:
1813        - If cached is True, the function returns the cached balance.
1814        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1815        """
1816        if cached:
1817            return self._vault['account'][account]['balance']
1818        x = 0
1819        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, optional): The account number. Default is '1'.
  • cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.

Returns:

  • int: The balance of the account.

Notes:

  • 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:
1821    def hide(self, account, status: bool = None) -> bool:
1822        """
1823        Check or set the hide status of a specific account.
1824
1825        Parameters:
1826        - account (str): The account number.
1827        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
1828
1829        Returns:
1830        - bool: The current or updated hide status of the account.
1831
1832        Raises:
1833        None
1834
1835        Example:
1836        >>> tracker = ZakatTracker()
1837        >>> ref = tracker.track(51, 'desc', 'account1')
1838        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1839        False
1840        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1841        True
1842        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1843        True
1844        >>> tracker.hide('account1', False)
1845        False
1846        """
1847        if self.account_exists(account):
1848            if status is None:
1849                return self._vault['account'][account]['hide']
1850            self._vault['account'][account]['hide'] = status
1851            return status
1852        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:
1854    def zakatable(self, account, status: bool = None) -> bool:
1855        """
1856        Check or set the zakatable status of a specific account.
1857
1858        Parameters:
1859        - account (str): The account number.
1860        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1861
1862        Returns:
1863        - bool: The current or updated zakatable status of the account.
1864
1865        Raises:
1866        None
1867
1868        Example:
1869        >>> tracker = ZakatTracker()
1870        >>> ref = tracker.track(51, 'desc', 'account1')
1871        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1872        True
1873        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1874        True
1875        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1876        True
1877        >>> tracker.zakatable('account1', False)
1878        False
1879        """
1880        if self.account_exists(account):
1881            if status is None:
1882                return self._vault['account'][account]['zakatable']
1883            self._vault['account'][account]['zakatable'] = status
1884            return status
1885        return False

Check or set the zakatable status of a specific account.

Parameters:

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

Returns:

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

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
True
>>> tracker.zakatable('account1', False)
False
def sub( self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: str = 1, created_time_ns: int = None, debug: bool = False) -> tuple[int, list[tuple[int, int]]] | tuple:
1887    def sub(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: str = 1,
1888            created_time_ns: int = None,
1889            debug: bool = False) \
1890            -> tuple[
1891                   int,
1892                   list[
1893                       tuple[int, int],
1894                   ],
1895               ] | tuple:
1896        """
1897        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
1898        the remaining amount will be transferred to a new transaction with a negative value.
1899
1900        Parameters:
1901        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
1902        - desc (str, optional): A description for the transaction. Defaults to an empty string.
1903        - account (str, optional): The account from which the value will be subtracted. Defaults to '1'.
1904        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
1905                                           If not provided, the current timestamp will be used.
1906        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
1907
1908        Returns:
1909        - tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1910
1911        Raises:
1912        - ValueError: The created_time_ns should be greater than zero.
1913        - ValueError: The box transaction happened again in the same nanosecond time.
1914        - ValueError: The log transaction happened again in the same nanosecond time.
1915        """
1916        if debug:
1917            print('sub', f'debug={debug}')
1918        if unscaled_value < 0:
1919            return tuple()
1920        if unscaled_value == 0:
1921            ref = self.track(unscaled_value, '', account)
1922            return ref, ref
1923        if created_time_ns is None:
1924            created_time_ns = self.time()
1925        if created_time_ns <= 0:
1926            raise ValueError('The created should be greater than zero.')
1927        no_lock = self.nolock()
1928        lock = self._lock()
1929        self.track(0, '', account)
1930        value = self.scale(unscaled_value)
1931        self._log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
1932        ids = sorted(self._vault['account'][account]['box'].keys())
1933        limit = len(ids) + 1
1934        target = value
1935        if debug:
1936            print('ids', ids)
1937        ages = []
1938        for i in range(-1, -limit, -1):
1939            if target == 0:
1940                break
1941            j = ids[i]
1942            if debug:
1943                print('i', i, 'j', j)
1944            rest = self._vault['account'][account]['box'][j]['rest']
1945            if rest >= target:
1946                self._vault['account'][account]['box'][j]['rest'] -= target
1947                self._step(Action.SUB, account, ref=j, value=target)
1948                ages.append((j, target))
1949                target = 0
1950                break
1951            elif target > rest > 0:
1952                chunk = rest
1953                target -= chunk
1954                self._vault['account'][account]['box'][j]['rest'] = 0
1955                self._step(Action.SUB, account, ref=j, value=chunk)
1956                ages.append((j, chunk))
1957        if target > 0:
1958            self.track(
1959                unscaled_value=self.unscale(-target),
1960                desc=desc,
1961                account=account,
1962                logging=False,
1963                created_time_ns=created_time_ns,
1964            )
1965            ages.append((created_time_ns, target))
1966        if no_lock:
1967            self.free(lock)
1968        return created_time_ns, ages

Subtracts a specified value from an account's balance, 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.

Parameters:

  • unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
  • desc (str, optional): A description for the transaction. Defaults to an empty string.
  • account (str, optional): The account from which the value will be subtracted. Defaults to '1'.
  • created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
  • debug (bool, optional): 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.

Raises:

  • ValueError: The created_time_ns should be greater than zero.
  • ValueError: The box transaction happened again in the same nanosecond time.
  • ValueError: The log transaction happened again in the same nanosecond time.
def transfer( self, unscaled_amount: float | int | decimal.Decimal, from_account: str, to_account: str, desc: str = '', created_time_ns: int = None, debug: bool = False) -> list[int]:
1970    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: str, to_account: str, desc: str = '',
1971                 created_time_ns: int = None,
1972                 debug: bool = False) -> list[int]:
1973        """
1974        Transfers a specified value from one account to another.
1975
1976        Parameters:
1977        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
1978        - from_account (str): The account from which the value will be transferred.
1979        - to_account (str): The account to which the value will be transferred.
1980        - desc (str, optional): A description for the transaction. Defaults to an empty string.
1981        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
1982        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
1983
1984        Returns:
1985        - list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1986
1987        Raises:
1988        - ValueError: Transfer to the same account is forbidden.
1989        - ValueError: The created_time_ns should be greater than zero.
1990        - ValueError: The box transaction happened again in the same nanosecond time.
1991        - ValueError: The log transaction happened again in the same nanosecond time.
1992        """
1993        if debug:
1994            print('transfer', f'debug={debug}')
1995        if from_account == to_account:
1996            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1997        if unscaled_amount <= 0:
1998            return []
1999        if created_time_ns is None:
2000            created_time_ns = self.time()
2001        if created_time_ns <= 0:
2002            raise ValueError('The created should be greater than zero.')
2003        no_lock = self.nolock()
2004        lock = self._lock()
2005        (_, ages) = self.sub(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
2006        times = []
2007        source_exchange = self.exchange(from_account, created_time_ns)
2008        target_exchange = self.exchange(to_account, created_time_ns)
2009
2010        if debug:
2011            print('ages', ages)
2012
2013        for age, value in ages:
2014            target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']))
2015            if debug:
2016                print('target_amount', target_amount)
2017            # Perform the transfer
2018            if self.box_exists(to_account, age):
2019                if debug:
2020                    print('box_exists', age)
2021                capital = self._vault['account'][to_account]['box'][age]['capital']
2022                rest = self._vault['account'][to_account]['box'][age]['rest']
2023                if debug:
2024                    print(
2025                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2026                selected_age = age
2027                if rest + target_amount > capital:
2028                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
2029                    selected_age = ZakatTracker.time()
2030                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
2031                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
2032                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
2033                              created_time_ns=None, ref=None, debug=debug)
2034                times.append((age, y))
2035                continue
2036            if debug:
2037                print(
2038                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2039            y = self.track(
2040                unscaled_value=self.unscale(int(target_amount)),
2041                desc=desc,
2042                account=to_account,
2043                logging=True,
2044                created_time_ns=age,
2045                debug=debug,
2046            )
2047            times.append(y)
2048        if no_lock:
2049            self.free(lock)
2050        return times

Transfers a specified value from one account to another.

Parameters:

  • unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
  • from_account (str): The account from which the value will be transferred.
  • to_account (str): The account to which the value will be transferred.
  • desc (str, optional): A description for the transaction. Defaults to an empty string.
  • created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
  • debug (bool, optional): 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 created_time_ns should be greater than zero.
  • ValueError: The box transaction happened again in the same nanosecond time.
  • ValueError: The log transaction happened again in the same nanosecond time.
def check( self, silver_gram_price: float, unscaled_nisab: float | int | decimal.Decimal = None, debug: bool = False, created_time_ns: int = None, cycle: float = None) -> tuple[bool, list[int], dict[str, float | int | str]]:
2052    def check(self,
2053              silver_gram_price: float,
2054              unscaled_nisab: float | int | decimal.Decimal = None,
2055              debug: bool = False,
2056              created_time_ns: int = None,
2057              cycle: float = None) -> tuple[bool, list[int], dict[str, float | int | str]]:
2058        """
2059        Check the eligibility for Zakat based on the given parameters.
2060
2061        Parameters:
2062        - silver_gram_price (float): The price of a gram of silver.
2063        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
2064                        If not provided, it will be calculated based on the silver_gram_price.
2065        - debug (bool, optional): Flag to enable debug mode.
2066        - created_time_ns (int, optional): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
2067        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
2068
2069        Returns:
2070        - tuple[bool, list[int], dict[str, float | int | str]]: A tuple containing a boolean indicating the eligibility for Zakat,
2071            a list of brief statistics, and a dictionary containing the Zakat plan.
2072        """
2073        if debug:
2074            print('check', f'debug={debug}')
2075        if created_time_ns is None:
2076            created_time_ns = self.time()
2077        if cycle is None:
2078            cycle = ZakatTracker.TimeCycle()
2079        if unscaled_nisab is None:
2080            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
2081        nisab = self.scale(unscaled_nisab)
2082        plan = {}
2083        below_nisab = 0
2084        brief = [0, 0, 0]
2085        valid = False
2086        if debug:
2087            print('exchanges', self.exchanges())
2088        for x in self._vault['account']:
2089            if not self.zakatable(x):
2090                continue
2091            _box = self._vault['account'][x]['box']
2092            _log = self._vault['account'][x]['log']
2093            limit = len(_box) + 1
2094            ids = sorted(self._vault['account'][x]['box'].keys())
2095            for i in range(-1, -limit, -1):
2096                j = ids[i]
2097                rest = float(_box[j]['rest'])
2098                if rest <= 0:
2099                    continue
2100                exchange = self.exchange(x, created_time_ns=self.time())
2101                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
2102                brief[0] += rest
2103                index = limit + i - 1
2104                epoch = (created_time_ns - j) / cycle
2105                if debug:
2106                    print(f'Epoch: {epoch}', _box[j])
2107                if _box[j]['last'] > 0:
2108                    epoch = (created_time_ns - _box[j]['last']) / cycle
2109                if debug:
2110                    print(f'Epoch: {epoch}')
2111                epoch = math.floor(epoch)
2112                if debug:
2113                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
2114                if epoch == 0:
2115                    continue
2116                if debug:
2117                    print('Epoch - PASSED')
2118                brief[1] += rest
2119                if rest >= nisab:
2120                    total = 0
2121                    for _ in range(epoch):
2122                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
2123                    if total > 0:
2124                        if x not in plan:
2125                            plan[x] = {}
2126                        valid = True
2127                        brief[2] += total
2128                        plan[x][index] = {
2129                            'total': total,
2130                            'count': epoch,
2131                            'box_time': j,
2132                            'box_capital': _box[j]['capital'],
2133                            'box_rest': _box[j]['rest'],
2134                            'box_last': _box[j]['last'],
2135                            'box_total': _box[j]['total'],
2136                            'box_count': _box[j]['count'],
2137                            'box_log': _log[j]['desc'],
2138                            'exchange_rate': exchange['rate'],
2139                            'exchange_time': exchange['time'],
2140                            'exchange_desc': exchange['description'],
2141                        }
2142                else:
2143                    chunk = ZakatTracker.ZakatCut(float(rest))
2144                    if chunk > 0:
2145                        if x not in plan:
2146                            plan[x] = {}
2147                        if j not in plan[x].keys():
2148                            plan[x][index] = {}
2149                        below_nisab += rest
2150                        brief[2] += chunk
2151                        plan[x][index]['below_nisab'] = chunk
2152                        plan[x][index]['total'] = chunk
2153                        plan[x][index]['count'] = epoch
2154                        plan[x][index]['box_time'] = j
2155                        plan[x][index]['box_capital'] = _box[j]['capital']
2156                        plan[x][index]['box_rest'] = _box[j]['rest']
2157                        plan[x][index]['box_last'] = _box[j]['last']
2158                        plan[x][index]['box_total'] = _box[j]['total']
2159                        plan[x][index]['box_count'] = _box[j]['count']
2160                        plan[x][index]['box_log'] = _log[j]['desc']
2161                        plan[x][index]['exchange_rate'] = exchange['rate']
2162                        plan[x][index]['exchange_time'] = exchange['time']
2163                        plan[x][index]['exchange_desc'] = exchange['description']
2164        valid = valid or below_nisab >= nisab
2165        if debug:
2166            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
2167        return valid, brief, plan

Check the eligibility for Zakat based on the given parameters.

Parameters:

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

Returns:

  • tuple[bool, list[int], dict[str, float | int | str]]: 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, scaled_demand: int, positive_only: bool = True) -> dict[str, dict[str, float] | bool | int | float]:
2169    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict[str, dict[str, float] | bool | int | float]:
2170        """
2171        Build payment parts for the Zakat distribution.
2172
2173        Parameters:
2174        - scaled_demand (int): The total demand for payment in local currency.
2175        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
2176
2177        Returns:
2178        - dict[str, dict[str, float] | bool | int | float]: A dictionary containing the payment parts for each account. The dictionary has the following structure:
2179        {
2180            'account': {
2181                'account_id': {'balance': float, 'rate': float, 'part': float},
2182                ...
2183            },
2184            'exceed': bool,
2185            'demand': int,
2186            'total': float,
2187        }
2188        """
2189        total = 0
2190        parts = {
2191            'account': {},
2192            'exceed': False,
2193            'demand': int(round(scaled_demand)),
2194        }
2195        for x, y in self.accounts().items():
2196            if positive_only and y <= 0:
2197                continue
2198            total += float(y)
2199            exchange = self.exchange(x)
2200            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
2201        parts['total'] = total
2202        return parts

Build payment parts for the Zakat distribution.

Parameters:

  • scaled_demand (int): The total demand for payment in local currency.
  • positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.

Returns:

  • dict[str, dict[str, float] | bool | int | float]: 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': int, 'total': float, }
@staticmethod
def check_payment_parts( parts: dict[str, dict[str, float] | bool | int | float], debug: bool = False) -> int:
2204    @staticmethod
2205    def check_payment_parts(parts: dict[str, dict[str, float] | bool | int | float], debug: bool = False) -> int:
2206        """
2207        Checks the validity of payment parts.
2208
2209        Parameters:
2210        - parts (dict[str, dict[str, float] | bool | int | float]): A dictionary containing payment parts information.
2211        - debug (bool, optional): Flag to enable debug mode.
2212
2213        Returns:
2214        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
2215
2216        Error Codes:
2217        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
2218        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
2219        3: 'part' value in parts['account'][x] is less than 0.
2220        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
2221        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
2222        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
2223        """
2224        if debug:
2225            print('check_payment_parts', f'debug={debug}')
2226        for i in ['demand', 'account', 'total', 'exceed']:
2227            if i not in parts:
2228                return 1
2229        exceed = parts['exceed']
2230        for x in parts['account']:
2231            for j in ['balance', 'rate', 'part']:
2232                if j not in parts['account'][x]:
2233                    return 2
2234                if parts['account'][x]['part'] < 0:
2235                    return 3
2236                if not exceed and parts['account'][x]['balance'] <= 0:
2237                    return 4
2238        demand = parts['demand']
2239        z = 0
2240        for _, y in parts['account'].items():
2241            if not exceed and y['part'] > y['balance']:
2242                return 5
2243            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
2244        z = round(z, 2)
2245        demand = round(demand, 2)
2246        if debug:
2247            print('check_payment_parts', f'z = {z}, demand = {demand}')
2248            print('check_payment_parts', type(z), type(demand))
2249            print('check_payment_parts', z != demand)
2250            print('check_payment_parts', str(z) != str(demand))
2251        if z != demand and str(z) != str(demand):
2252            return 6
2253        return 0

Checks the validity of payment parts.

Parameters:

  • parts (dict[str, dict[str, float] | bool | int | float]): A dictionary containing payment parts information.
  • debug (bool, optional): 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[bool, list[int], dict[str, float | int | str]], parts: dict[str, dict[str, float] | bool | int | float] = None, debug: bool = False) -> bool:
2255    def zakat(self, report: tuple[bool, list[int], dict[str, float | int | str]],
2256        parts: dict[str, dict[str, float] | bool | int | float] = None, debug: bool = False) -> bool:
2257        """
2258        Perform Zakat calculation based on the given report and optional parts.
2259
2260        Parameters:
2261        - report (tuple[bool, list[int], dict[str, float | int | str]]): A tuple containing the validity of the report, the report data, and the zakat plan.
2262        - parts (dict[str, dict[str, float] | bool | int | float], optional): A dictionary containing the payment parts for the zakat.
2263        - debug (bool, optional): A flag indicating whether to print debug information.
2264
2265        Returns:
2266        - bool: True if the zakat calculation is successful, False otherwise.
2267        """
2268        if debug:
2269            print('zakat', f'debug={debug}')
2270        valid, _, plan = report
2271        if not valid:
2272            return valid
2273        parts_exist = parts is not None
2274        if parts_exist:
2275            if self.check_payment_parts(parts, debug=debug) != 0:
2276                return False
2277        if debug:
2278            print('######### zakat #######')
2279            print('parts_exist', parts_exist)
2280        no_lock = self.nolock()
2281        lock = self._lock()
2282        report_time = self.time()
2283        self._vault['report'][report_time] = report
2284        self._step(Action.REPORT, ref=report_time)
2285        created_time_ns = self.time()
2286        for x in plan:
2287            target_exchange = self.exchange(x)
2288            if debug:
2289                print(plan[x])
2290                print('-------------')
2291                print(self._vault['account'][x]['box'])
2292            ids = sorted(self._vault['account'][x]['box'].keys())
2293            if debug:
2294                print('plan[x]', plan[x])
2295            for i in plan[x].keys():
2296                j = ids[i]
2297                if debug:
2298                    print('i', i, 'j', j)
2299                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
2300                           key='last',
2301                           math_operation=MathOperation.EQUAL)
2302                self._vault['account'][x]['box'][j]['last'] = created_time_ns
2303                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
2304                self._vault['account'][x]['box'][j]['total'] += amount
2305                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
2306                           math_operation=MathOperation.ADDITION)
2307                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
2308                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
2309                           math_operation=MathOperation.ADDITION)
2310                if not parts_exist:
2311                    try:
2312                        self._vault['account'][x]['box'][j]['rest'] -= amount
2313                    except TypeError:
2314                        self._vault['account'][x]['box'][j]['rest'] -= decimal.Decimal(amount)
2315                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
2316                    #            math_operation=MathOperation.SUBTRACTION)
2317                    self._log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
2318        if parts_exist:
2319            for account, part in parts['account'].items():
2320                if part['part'] == 0:
2321                    continue
2322                if debug:
2323                    print('zakat-part', account, part['rate'])
2324                target_exchange = self.exchange(account)
2325                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
2326                self.sub(
2327                    unscaled_value=self.unscale(int(amount)),
2328                    desc='zakat-part-دفعة-زكاة',
2329                    account=account,
2330                    debug=debug,
2331                )
2332        if no_lock:
2333            self.free(lock)
2334        return True

Perform Zakat calculation based on the given report and optional parts.

Parameters:

  • report (tuple[bool, list[int], dict[str, float | int | str]]): A tuple containing the validity of the report, the report data, and the zakat plan.
  • parts (dict[str, dict[str, float] | bool | int | float], optional): A dictionary containing the payment parts for the zakat.
  • debug (bool, optional): 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:
2336    def export_json(self, path: str = 'data.json') -> bool:
2337        """
2338        Exports the current state of the ZakatTracker object to a JSON file.
2339
2340        Parameters:
2341        - path (str, optional): The path where the JSON file will be saved. Default is 'data.json'.
2342
2343        Returns:
2344        - bool: True if the export is successful, False otherwise.
2345
2346        Raises:
2347        No specific exceptions are raised by this method.
2348        """
2349        with open(path, 'w', encoding='utf-8') as file:
2350            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
2351            return True

Exports the current state of the ZakatTracker object to a JSON file.

Parameters:

  • path (str, optional): 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.

@staticmethod
def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
2353    @staticmethod
2354    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
2355        """Splits a string at the last occurrence of a given symbol.
2356    
2357        Parameters:
2358        - data (str): The input string.
2359        - symbol (str): The symbol to split at.
2360    
2361        Returns:
2362        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
2363            the part after the last symbol. If the symbol is not found, returns (data, "").
2364        """
2365        last_symbol_index = data.rfind(symbol)
2366    
2367        if last_symbol_index != -1:
2368            before_symbol = data[:last_symbol_index]
2369            after_symbol = data[last_symbol_index + 1:]
2370            return before_symbol, after_symbol
2371        return data, ""

Splits a string at the last occurrence of a given symbol.

Parameters:

  • data (str): The input string.
  • symbol (str): The symbol to split at.

Returns:

  • tuple[str, str]: A tuple containing two strings, the part before the last symbol and the part after the last symbol. If the symbol is not found, returns (data, "").
def save(self, path: str = None, hash_required: bool = True) -> bool:
2373    def save(self, path: str = None, hash_required: bool = True) -> bool:
2374        """
2375        Saves the ZakatTracker's current state to a camel file.
2376
2377        This method serializes the internal data (`_vault`).
2378
2379        Parameters:
2380        - path (str, optional): File path for saving. Defaults to a predefined location.
2381        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
2382
2383        Returns:
2384        - bool: True if the save operation is successful, False otherwise.
2385        """
2386        if path is None:
2387            path = self.path()
2388        # first save in tmp file
2389        temp = f'{path}.tmp'
2390        try:
2391            with open(temp, 'w', encoding='utf-8') as stream:
2392                data = camel.dump(self._vault)
2393                stream.write(data)
2394                if hash_required:
2395                    hashed = self.hash_data(data.encode())
2396                    stream.write(f'#{hashed}')
2397            # then move tmp file to original location
2398            shutil.move(temp, path)
2399            return True
2400        except (IOError, OSError) as e:
2401            print(f'Error saving file: {e}')
2402            if os.path.exists(temp):
2403                os.remove(temp)
2404            return False

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

This method serializes the internal data (_vault).

Parameters:

  • path (str, optional): File path for saving. Defaults to a predefined location.
  • hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.

Returns:

  • bool: True if the save operation is successful, False otherwise.
def load(self, path: str = None, hash_required: bool = True) -> bool:
2406    def load(self, path: str = None, hash_required: bool = True) -> bool:
2407        """
2408        Load the current state of the ZakatTracker object from a camel file.
2409
2410        Parameters:
2411        - path (str, optional): The path where the camel file is located. If not provided, it will use the default path.
2412        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
2413
2414        Returns:
2415        - bool: True if the load operation is successful, False otherwise.
2416        """
2417        if path is None:
2418            path = self.path()
2419        try:
2420            if os.path.exists(path):
2421                with open(path, 'r', encoding='utf-8') as stream:
2422                    file = stream.read()
2423                    data, hashed = self.split_at_last_symbol(file, '#')
2424                    if hash_required:
2425                        new_hash = self.hash_data(data.encode())
2426                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
2427                    self._vault = camel.load(data)
2428                return True
2429            else:
2430                print(f'File not found: {path}')
2431                return False
2432        except (IOError, OSError) as e:
2433            print(f'Error loading file: {e}')
2434            return False

Load the current state of the ZakatTracker object from a camel file.

Parameters:

  • path (str, optional): The path where the camel file is located. If not provided, it will use the default path.
  • hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.

Returns:

  • bool: True if the load operation is successful, False otherwise.
def import_csv_cache_path(self):
2436    def import_csv_cache_path(self):
2437        """
2438        Generates the cache file path for imported CSV data.
2439
2440        This function constructs the file path where cached data from CSV imports
2441        will be stored. The cache file is a camel file (.camel extension) appended
2442        to the base path of the object.
2443
2444        Parameters:
2445        None
2446
2447        Returns:
2448        - str: The full path to the import CSV cache file.
2449
2450        Example:
2451            >>> obj = ZakatTracker('/data/reports')
2452            >>> obj.import_csv_cache_path()
2453            '/data/reports.import_csv.camel'
2454        """
2455        path = str(self.path())
2456        ext = self.ext()
2457        ext_len = len(ext)
2458        if path.endswith(f'.{ext}'):
2459            path = path[:-ext_len - 1]
2460        _, filename = os.path.split(path + f'.import_csv.{ext}')
2461        return self.base_path(filename)

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 camel file (.camel extension) appended to the base path of the object.

Parameters: None

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.camel'

def import_csv( self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
2463    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
2464        """
2465        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
2466
2467        Parameters:
2468        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
2469        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
2470        - debug (bool, optional): A flag indicating whether to print debug information.
2471
2472        Returns:
2473        - tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
2474                and a dictionary of bad transactions.
2475
2476        Notes:
2477        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
2478                                    are appropriate for the currency pairs involved in the conversions.
2479        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
2480            to 1.0 or the previous rate for that account.
2481        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
2482            transactions of the same account within the whole imported and existing dataset when doing `check` and
2483            `zakat` operations.
2484
2485        Example:
2486            The CSV file should have the following format, rate is optional per transaction:
2487            account, desc, value, date, rate
2488            For example:
2489            safe-45, 'Some text', 34872, 1988-06-30 00:00:00, 1
2490        """
2491        if debug:
2492            print('import_csv', f'debug={debug}')
2493        cache: list[int] = []
2494        try:
2495            with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
2496                cache = camel.load(stream.read())
2497        except:
2498            pass
2499        date_formats = [
2500            '%Y-%m-%d %H:%M:%S',
2501            '%Y-%m-%dT%H:%M:%S',
2502            '%Y-%m-%dT%H%M%S.%f',
2503            '%Y-%m-%d',
2504        ]
2505        created, found, bad = 0, 0, {}
2506        data: dict[int, list] = {}
2507        with open(path, newline='', encoding='utf-8') as f:
2508            i = 0
2509            for row in csv.reader(f, delimiter=','):
2510                i += 1
2511                hashed = hash(tuple(row))
2512                if hashed in cache:
2513                    found += 1
2514                    continue
2515                account = row[0]
2516                desc = row[1]
2517                value = float(row[2])
2518                rate = 1.0
2519                if row[4:5]:  # Empty list if index is out of range
2520                    rate = float(row[4])
2521                date: int = 0
2522                for time_format in date_formats:
2523                    try:
2524                        date = self.time(datetime.datetime.strptime(row[3], time_format))
2525                        break
2526                    except:
2527                        pass
2528                if date <= 0:
2529                    bad[i] = row + ['invalid date']
2530                if value == 0:
2531                    bad[i] = row + ['invalid value']
2532                    continue
2533                if date not in data:
2534                    data[date] = []
2535                data[date].append((i, account, desc, value, date, rate, hashed))
2536
2537        if debug:
2538            print('import_csv', len(data))
2539
2540        if bad:
2541            return created, found, bad
2542
2543        no_lock = self.nolock()
2544        lock = self._lock()
2545        for date, rows in sorted(data.items()):
2546            try:
2547                len_rows = len(rows)
2548                if len_rows == 1:
2549                    (_, account, desc, unscaled_value, date, rate, hashed) = rows[0]
2550                    value = self.unscale(
2551                        unscaled_value,
2552                        decimal_places=scale_decimal_places,
2553                    ) if scale_decimal_places > 0 else unscaled_value
2554                    if rate > 0:
2555                        self.exchange(account=account, created_time_ns=date, rate=rate)
2556                    if value > 0:
2557                        self.track(unscaled_value=value, desc=desc, account=account, logging=True, created_time_ns=date)
2558                    elif value < 0:
2559                        self.sub(unscaled_value=-value, desc=desc, account=account, created_time_ns=date)
2560                    created += 1
2561                    cache.append(hashed)
2562                    continue
2563                if debug:
2564                    print('-- Duplicated time detected', date, 'len', len_rows)
2565                    print(rows)
2566                    print('---------------------------------')
2567                # If records are found at the same time with different accounts in the same amount
2568                # (one positive and the other negative), this indicates it is a transfer.
2569                if len_rows != 2:
2570                    raise Exception(f'more than two transactions({len_rows}) at the same time')
2571                (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0]
2572                (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1]
2573                if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(
2574                        unscaled_value2) or date1 != date2:
2575                    raise Exception('invalid transfer')
2576                if rate1 > 0:
2577                    self.exchange(account1, created_time_ns=date1, rate=rate1)
2578                if rate2 > 0:
2579                    self.exchange(account2, created_time_ns=date2, rate=rate2)
2580                value1 = self.unscale(
2581                    unscaled_value1,
2582                    decimal_places=scale_decimal_places,
2583                ) if scale_decimal_places > 0 else unscaled_value1
2584                value2 = self.unscale(
2585                    unscaled_value2,
2586                    decimal_places=scale_decimal_places,
2587                ) if scale_decimal_places > 0 else unscaled_value2
2588                values = {
2589                    value1: account1,
2590                    value2: account2,
2591                }
2592                self.transfer(
2593                    unscaled_amount=abs(value1),
2594                    from_account=values[min(values.keys())],
2595                    to_account=values[max(values.keys())],
2596                    desc=desc1,
2597                    created_time_ns=date1,
2598                )
2599            except Exception as e:
2600                for (i, account, desc, value, date, rate, _) in rows:
2601                    bad[i] = (account, desc, value, date, rate, e)
2602                break
2603        with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
2604            stream.write(camel.dump(cache))
2605        if no_lock:
2606            self.free(lock)
2607        y = created, found, bad
2608        if debug:
2609            debug_path = f'{self.import_csv_cache_path()}.debug.json'
2610            with open(debug_path, 'w', encoding='utf-8') as file:
2611                json.dump(y, file, indent=4, cls=JSONEncoder)
2612                print(f'generated debug report @ `{debug_path}`...')
2613        return y

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

Parameters:

  • path (str, optional): The path to the CSV file. Default is 'file.csv'.
  • scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
  • debug (bool, optional): 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: 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:
2619    @staticmethod
2620    def human_readable_size(size: float, decimal_places: int = 2) -> str:
2621        """
2622        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
2623
2624        This function iterates through progressively larger units of information
2625        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
2626        range that can be expressed with a reasonable number before the unit.
2627
2628        Parameters:
2629        - size (float): The size in bytes to convert.
2630        - decimal_places (int, optional): The number of decimal places to display
2631            in the result. Defaults to 2.
2632
2633        Returns:
2634        - str: A string representation of the size in a human-readable format,
2635            rounded to the specified number of decimal places. For example:
2636                - '1.50 KB' (1536 bytes)
2637                - '23.00 MB' (24117248 bytes)
2638                - '1.23 GB' (1325899906 bytes)
2639        """
2640        if type(size) not in (float, int):
2641            raise TypeError('size must be a float or integer')
2642        if type(decimal_places) != int:
2643            raise TypeError('decimal_places must be an integer')
2644        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
2645            if size < 1024.0:
2646                break
2647            size /= 1024.0
2648        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:
2650    @staticmethod
2651    def get_dict_size(obj: dict, seen: set = None) -> float:
2652        """
2653        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
2654
2655        This function traverses the dictionary structure, accounting for the size of keys, values,
2656        and any nested objects. It handles various data types commonly found in dictionaries
2657        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
2658        of circular references.
2659
2660        Parameters:
2661        - obj (dict): The dictionary whose size is to be calculated.
2662        - seen (set, optional): A set used internally to track visited objects
2663                             and avoid circular references. Defaults to None.
2664
2665        Returns:
2666         - float: An approximate size of the dictionary and its contents in bytes.
2667
2668        Notes:
2669        - This function is a method of the `ZakatTracker` class and is likely used to
2670          estimate the memory footprint of data structures relevant to Zakat calculations.
2671        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
2672          not account for all memory overhead depending on the Python implementation.
2673        - Circular references are handled to prevent infinite recursion.
2674        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
2675        - String sizes are estimated based on character length and encoding.
2676        """
2677        size = 0
2678        if seen is None:
2679            seen = set()
2680
2681        obj_id = id(obj)
2682        if obj_id in seen:
2683            return 0
2684
2685        seen.add(obj_id)
2686        size += sys.getsizeof(obj)
2687
2688        if isinstance(obj, dict):
2689            for k, v in obj.items():
2690                size += ZakatTracker.get_dict_size(k, seen)
2691                size += ZakatTracker.get_dict_size(v, seen)
2692        elif isinstance(obj, (list, tuple, set, frozenset)):
2693            for item in obj:
2694                size += ZakatTracker.get_dict_size(item, seen)
2695        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2696            pass  # Basic numbers have a fixed size, so nothing to add here
2697        elif isinstance(obj, str):  # Handle strings
2698            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2699        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.

Notes:

  • 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:
2701    @staticmethod
2702    def duration_from_nanoseconds(ns: int,
2703                                  show_zeros_in_spoken_time: bool = False,
2704                                  spoken_time_separator=',',
2705                                  millennia: str = 'Millennia',
2706                                  century: str = 'Century',
2707                                  years: str = 'Years',
2708                                  days: str = 'Days',
2709                                  hours: str = 'Hours',
2710                                  minutes: str = 'Minutes',
2711                                  seconds: str = 'Seconds',
2712                                  milli_seconds: str = 'MilliSeconds',
2713                                  micro_seconds: str = 'MicroSeconds',
2714                                  nano_seconds: str = 'NanoSeconds',
2715                                  ) -> tuple:
2716        """
2717        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2718        Convert NanoSeconds to Human Readable Time Format.
2719        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2720        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2721        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2722        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2723
2724        INPUT : ms (AKA: MilliSeconds)
2725        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2726        OUTPUT Variables: time_lapsed, spoken_time
2727
2728        Example  Input: duration_from_nanoseconds(ns)
2729        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
2730        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')
2731        duration_from_nanoseconds(1234567890123456789012)
2732        """
2733        us, ns = divmod(ns, 1000)
2734        ms, us = divmod(us, 1000)
2735        s, ms = divmod(ms, 1000)
2736        m, s = divmod(s, 60)
2737        h, m = divmod(m, 60)
2738        d, h = divmod(h, 24)
2739        y, d = divmod(d, 365)
2740        c, y = divmod(y, 100)
2741        n, c = divmod(c, 10)
2742        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}'
2743        spoken_time_part = []
2744        if n > 0 or show_zeros_in_spoken_time:
2745            spoken_time_part.append(f'{n: 3d} {millennia}')
2746        if c > 0 or show_zeros_in_spoken_time:
2747            spoken_time_part.append(f'{c: 4d} {century}')
2748        if y > 0 or show_zeros_in_spoken_time:
2749            spoken_time_part.append(f'{y: 3d} {years}')
2750        if d > 0 or show_zeros_in_spoken_time:
2751            spoken_time_part.append(f'{d: 4d} {days}')
2752        if h > 0 or show_zeros_in_spoken_time:
2753            spoken_time_part.append(f'{h: 2d} {hours}')
2754        if m > 0 or show_zeros_in_spoken_time:
2755            spoken_time_part.append(f'{m: 2d} {minutes}')
2756        if s > 0 or show_zeros_in_spoken_time:
2757            spoken_time_part.append(f'{s: 2d} {seconds}')
2758        if ms > 0 or show_zeros_in_spoken_time:
2759            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
2760        if us > 0 or show_zeros_in_spoken_time:
2761            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
2762        if ns > 0 or show_zeros_in_spoken_time:
2763            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
2764        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:
2766    @staticmethod
2767    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2768        """
2769        Convert a specific day, month, and year into a timestamp.
2770
2771        Parameters:
2772        - day (int): The day of the month.
2773        - month (int, optional): The month of the year. Default is 6 (June).
2774        - year (int, optional): The year. Default is 2024.
2775
2776        Returns:
2777        - int: The timestamp representing the given day, month, and year.
2778
2779        Note:
2780        - This method assumes the default month and year if not provided.
2781        """
2782        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, optional): The month of the year. Default is 6 (June).
  • year (int, optional): 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:
2784    @staticmethod
2785    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2786        """
2787        Generate a random date between two given dates.
2788
2789        Parameters:
2790        - start_date (datetime.datetime): The start date from which to generate a random date.
2791        - end_date (datetime.datetime): The end date until which to generate a random date.
2792
2793        Returns:
2794        - datetime.datetime: A random date between the start_date and end_date.
2795        """
2796        time_between_dates = end_date - start_date
2797        days_between_dates = time_between_dates.days
2798        random_number_of_days = random.randrange(days_between_dates)
2799        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:
2801    @staticmethod
2802    def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False,
2803                                 debug: bool = False) -> int:
2804        """
2805        Generate a random CSV file with specified parameters.
2806        The function generates a CSV file at the specified path with the given count of rows.
2807        Each row contains a randomly generated account, description, value, and date.
2808        The value is randomly generated between 1000 and 100000,
2809        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2810        If the row number is not divisible by 13, the value is multiplied by -1.
2811
2812        Parameters:
2813        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
2814        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
2815        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
2816        - debug (bool, optional): A flag indicating whether to print debug information.
2817
2818        Returns:
2819        None
2820        """
2821        if debug:
2822            print('generate_random_csv_file', f'debug={debug}')
2823        i = 0
2824        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
2825            writer = csv.writer(csvfile)
2826            for i in range(count):
2827                account = f'acc-{random.randint(1, count)}'
2828                desc = f'Some text {random.randint(1, count)}'
2829                value = random.randint(1000, 100000)
2830                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2831                                                         datetime.datetime(2023, 12, 31)).strftime('%Y-%m-%d %H:%M:%S')
2832                if not i % 13 == 0:
2833                    value *= -1
2834                row = [account, desc, value, date]
2835                if with_rate:
2836                    rate = random.randint(1, 100) * 0.12
2837                    if debug:
2838                        print('before-append', row)
2839                    row.append(rate)
2840                    if debug:
2841                        print('after-append', row)
2842                writer.writerow(row)
2843                i = i + 1
2844        return i

Generate a random CSV file with specified parameters. 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.

Parameters:

  • path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
  • count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
  • with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
  • debug (bool, optional): A flag indicating whether to print debug information.

Returns: None

@staticmethod
def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
2846    @staticmethod
2847    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
2848        """
2849        Creates a list of random integers whose sum does not exceed the specified maximum.
2850
2851        Parameters:
2852        - max_sum (int): The maximum allowed sum of the list elements.
2853        - min_value (int, optional): The minimum possible value for an element (inclusive).
2854        - max_value (int, optional): The maximum possible value for an element (inclusive).
2855
2856        Returns:
2857        - A list of random integers.
2858        """
2859        result = []
2860        current_sum = 0
2861
2862        while current_sum < max_sum:
2863            # Calculate the remaining space for the next element
2864            remaining_sum = max_sum - current_sum
2865            # Determine the maximum possible value for the next element
2866            next_max_value = min(remaining_sum, max_value)
2867            # Generate a random element within the allowed range
2868            next_element = random.randint(min_value, next_max_value)
2869            result.append(next_element)
2870            current_sum += next_element
2871
2872        return result

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

Parameters:

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

Returns:

  • A list of random integers.
def test(self, debug: bool = False) -> bool:
3169    def test(self, debug: bool = False) -> bool:
3170        if debug:
3171            print('test', f'debug={debug}')
3172        try:
3173
3174            self._test_core(True, debug)
3175            self._test_core(False, debug)
3176
3177            assert self._history()
3178
3179            # Not allowed for duplicate transactions in the same account and time
3180
3181            created = ZakatTracker.time()
3182            self.track(100, 'test-1', 'same', True, created)
3183            failed = False
3184            try:
3185                self.track(50, 'test-1', 'same', True, created)
3186            except:
3187                failed = True
3188            assert failed is True
3189
3190            self.reset()
3191
3192            # Same account transfer
3193            for x in [1, 'a', True, 1.8, None]:
3194                failed = False
3195                try:
3196                    self.transfer(1, x, x, 'same-account', debug=debug)
3197                except:
3198                    failed = True
3199                assert failed is True
3200
3201            # Always preserve box age during transfer
3202
3203            series: list[tuple] = [
3204                (30, 4),
3205                (60, 3),
3206                (90, 2),
3207            ]
3208            case = {
3209                3000: {
3210                    'series': series,
3211                    'rest': 15000,
3212                },
3213                6000: {
3214                    'series': series,
3215                    'rest': 12000,
3216                },
3217                9000: {
3218                    'series': series,
3219                    'rest': 9000,
3220                },
3221                18000: {
3222                    'series': series,
3223                    'rest': 0,
3224                },
3225                27000: {
3226                    'series': series,
3227                    'rest': -9000,
3228                },
3229                36000: {
3230                    'series': series,
3231                    'rest': -18000,
3232                },
3233            }
3234
3235            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
3236
3237            for total in case:
3238                if debug:
3239                    print('--------------------------------------------------------')
3240                    print(f'case[{total}]', case[total])
3241                for x in case[total]['series']:
3242                    self.track(
3243                        unscaled_value=x[0],
3244                        desc=f'test-{x} ages',
3245                        account='ages',
3246                        logging=True,
3247                        created_time_ns=selected_time * x[1],
3248                    )
3249
3250                unscaled_total = self.unscale(total)
3251                if debug:
3252                    print('unscaled_total', unscaled_total)
3253                refs = self.transfer(
3254                    unscaled_amount=unscaled_total,
3255                    from_account='ages',
3256                    to_account='future',
3257                    desc='Zakat Movement',
3258                    debug=debug,
3259                )
3260
3261                if debug:
3262                    print('refs', refs)
3263
3264                ages_cache_balance = self.balance('ages')
3265                ages_fresh_balance = self.balance('ages', False)
3266                rest = case[total]['rest']
3267                if debug:
3268                    print('source', ages_cache_balance, ages_fresh_balance, rest)
3269                assert ages_cache_balance == rest
3270                assert ages_fresh_balance == rest
3271
3272                future_cache_balance = self.balance('future')
3273                future_fresh_balance = self.balance('future', False)
3274                if debug:
3275                    print('target', future_cache_balance, future_fresh_balance, total)
3276                    print('refs', refs)
3277                assert future_cache_balance == total
3278                assert future_fresh_balance == total
3279
3280                # TODO: check boxes times for `ages` should equal box times in `future`
3281                for ref in self._vault['account']['ages']['box']:
3282                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
3283                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
3284                    future_capital = 0
3285                    if ref in self._vault['account']['future']['box']:
3286                        future_capital = self._vault['account']['future']['box'][ref]['capital']
3287                    future_rest = 0
3288                    if ref in self._vault['account']['future']['box']:
3289                        future_rest = self._vault['account']['future']['box'][ref]['rest']
3290                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
3291                        if debug:
3292                            print('================================================================')
3293                            print('ages', ages_capital, ages_rest)
3294                            print('future', future_capital, future_rest)
3295                        if ages_rest == 0:
3296                            assert ages_capital == future_capital
3297                        elif ages_rest < 0:
3298                            assert -ages_capital == future_capital
3299                        elif ages_rest > 0:
3300                            assert ages_capital == ages_rest + future_capital
3301                self.reset()
3302                assert len(self._vault['history']) == 0
3303
3304            assert self._history()
3305            assert self._history(False) is False
3306            assert self._history() is False
3307            assert self._history(True)
3308            assert self._history()
3309            if debug:
3310                print('####################################################################')
3311
3312            transaction = [
3313                (
3314                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
3315                    2000, 2000, 2000, 1, 1,
3316                ),
3317                (
3318                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
3319                    75000, 75000, 75000, 1, 1,
3320                ),
3321                (
3322                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
3323                    60000, 60000, 60000, 1, 1,
3324                ),
3325            ]
3326            for z in transaction:
3327                lock = self.lock()
3328                x = z[1]
3329                y = z[2]
3330                self.transfer(
3331                    unscaled_amount=z[0],
3332                    from_account=x,
3333                    to_account=y,
3334                    desc='test-transfer',
3335                    debug=debug,
3336                )
3337                zz = self.balance(x)
3338                if debug:
3339                    print(zz, z)
3340                assert zz == z[3]
3341                xx = self.accounts()[x]
3342                assert xx == z[3]
3343                assert self.balance(x, False) == z[4]
3344                assert xx == z[4]
3345
3346                s = 0
3347                log = self._vault['account'][x]['log']
3348                for i in log:
3349                    s += log[i]['value']
3350                if debug:
3351                    print('s', s, 'z[5]', z[5])
3352                assert s == z[5]
3353
3354                assert self.box_size(x) == z[6]
3355                assert self.log_size(x) == z[7]
3356
3357                yy = self.accounts()[y]
3358                assert self.balance(y) == z[8]
3359                assert yy == z[8]
3360                assert self.balance(y, False) == z[9]
3361                assert yy == z[9]
3362
3363                s = 0
3364                log = self._vault['account'][y]['log']
3365                for i in log:
3366                    s += log[i]['value']
3367                assert s == z[10]
3368
3369                assert self.box_size(y) == z[11]
3370                assert self.log_size(y) == z[12]
3371                assert self.free(lock)
3372
3373            if debug:
3374                pp().pprint(self.check(2.17))
3375
3376            assert self.nolock()
3377            history_count = len(self._vault['history'])
3378            if debug:
3379                print('history-count', history_count)
3380            transaction_count = len(transaction)
3381            assert history_count == transaction_count
3382            assert not self.free(ZakatTracker.time())
3383            assert self.free(self.lock())
3384            assert self.nolock()
3385            assert len(self._vault['history']) == transaction_count
3386
3387            # storage
3388
3389            _path = self.path(f'./zakat_test_db/test.{self.ext()}')
3390            if os.path.exists(_path):
3391                os.remove(_path)
3392            for hashed in [False, True]:
3393                self.save(hash_required=hashed)
3394                assert os.path.getsize(_path) > 0
3395                self.reset()
3396                assert self.recall(False, debug=debug) is False
3397                for hash_required in [False, True]:
3398                    if debug:
3399                        print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
3400                    self.load(hash_required=hashed and hash_required)
3401                    assert self._vault['account'] is not None
3402                if hashed:
3403                    continue
3404                failed = False
3405                try:
3406                    hash_required = True
3407                    if debug:
3408                        print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
3409                    self.load(hash_required=True)
3410                except:
3411                    failed = True
3412                assert failed
3413
3414            # recall
3415
3416            assert self.nolock()
3417            assert len(self._vault['history']) == 3
3418            assert self.recall(False, debug=debug) is True
3419            assert len(self._vault['history']) == 2
3420            assert self.recall(False, debug=debug) is True
3421            assert len(self._vault['history']) == 1
3422            assert self.recall(False, debug=debug) is True
3423            assert len(self._vault['history']) == 0
3424            assert self.recall(False, debug=debug) is False
3425            assert len(self._vault['history']) == 0
3426
3427            # exchange
3428
3429            self.exchange('cash', 25, 3.75, '2024-06-25')
3430            self.exchange('cash', 22, 3.73, '2024-06-22')
3431            self.exchange('cash', 15, 3.69, '2024-06-15')
3432            self.exchange('cash', 10, 3.66)
3433
3434            assert self.nolock()
3435
3436            for i in range(1, 30):
3437                exchange = self.exchange('cash', i)
3438                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3439                if debug:
3440                    print(i, rate, description, created)
3441                assert created
3442                if i < 10:
3443                    assert rate == 1
3444                    assert description is None
3445                elif i == 10:
3446                    assert rate == 3.66
3447                    assert description is None
3448                elif i < 15:
3449                    assert rate == 3.66
3450                    assert description is None
3451                elif i == 15:
3452                    assert rate == 3.69
3453                    assert description is not None
3454                elif i < 22:
3455                    assert rate == 3.69
3456                    assert description is not None
3457                elif i == 22:
3458                    assert rate == 3.73
3459                    assert description is not None
3460                elif i >= 25:
3461                    assert rate == 3.75
3462                    assert description is not None
3463                exchange = self.exchange('bank', i)
3464                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3465                if debug:
3466                    print(i, rate, description, created)
3467                assert created
3468                assert rate == 1
3469                assert description is None
3470
3471            assert len(self._vault['exchange']) == 1
3472            assert len(self.exchanges()) == 1
3473            self._vault['exchange'].clear()
3474            assert len(self._vault['exchange']) == 0
3475            assert len(self.exchanges()) == 0
3476            self.reset()
3477
3478            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
3479            self.exchange('cash', ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
3480            self.exchange('cash', ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
3481            self.exchange('cash', ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
3482            self.exchange('cash', ZakatTracker.day_to_time(10), 3.66)
3483
3484            assert self.nolock()
3485
3486            for i in [x * 0.12 for x in range(-15, 21)]:
3487                if i <= 0:
3488                    assert len(self.exchange('test', ZakatTracker.time(), i, f'range({i})')) == 0
3489                else:
3490                    assert len(self.exchange('test', ZakatTracker.time(), i, f'range({i})')) > 0
3491
3492            assert self.nolock()
3493
3494           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
3495            for i in range(1, 31):
3496                timestamp_ns = ZakatTracker.day_to_time(i)
3497                exchange = self.exchange('cash', timestamp_ns)
3498                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3499                if debug:
3500                    print(i, rate, description, created)
3501                assert created
3502                if i < 10:
3503                    assert rate == 1
3504                    assert description is None
3505                elif i == 10:
3506                    assert rate == 3.66
3507                    assert description is None
3508                elif i < 15:
3509                    assert rate == 3.66
3510                    assert description is None
3511                elif i == 15:
3512                    assert rate == 3.69
3513                    assert description is not None
3514                elif i < 22:
3515                    assert rate == 3.69
3516                    assert description is not None
3517                elif i == 22:
3518                    assert rate == 3.73
3519                    assert description is not None
3520                elif i >= 25:
3521                    assert rate == 3.75
3522                    assert description is not None
3523                exchange = self.exchange('bank', i)
3524                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3525                if debug:
3526                    print(i, rate, description, created)
3527                assert created
3528                assert rate == 1
3529                assert description is None
3530
3531            assert self.nolock()
3532
3533            self.reset()
3534
3535            # test transfer between accounts with different exchange rate
3536
3537            a_SAR = 'Bank (SAR)'
3538            b_USD = 'Bank (USD)'
3539            c_SAR = 'Safe (SAR)'
3540            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
3541            for case in [
3542                (0, a_SAR, 'SAR Gift', 1000, 100000),
3543                (1, a_SAR, 1),
3544                (0, b_USD, 'USD Gift', 500, 50000),
3545                (1, b_USD, 1),
3546                (2, b_USD, 3.75),
3547                (1, b_USD, 3.75),
3548                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
3549                (0, c_SAR, 'Salary', 750, 75000),
3550                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
3551                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
3552            ]:
3553                if debug:
3554                    print('case', case)
3555                match (case[0]):
3556                    case 0:  # track
3557                        _, account, desc, x, balance = case
3558                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
3559
3560                        cached_value = self.balance(account, cached=True)
3561                        fresh_value = self.balance(account, cached=False)
3562                        if debug:
3563                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
3564                        assert cached_value == balance
3565                        assert fresh_value == balance
3566                    case 1:  # check-exchange
3567                        _, account, expected_rate = case
3568                        t_exchange = self.exchange(account, created_time_ns=ZakatTracker.time(), debug=debug)
3569                        if debug:
3570                            print('t-exchange', t_exchange)
3571                        assert t_exchange['rate'] == expected_rate
3572                    case 2:  # do-exchange
3573                        _, account, rate = case
3574                        self.exchange(account, rate=rate, debug=debug)
3575                        b_exchange = self.exchange(account, created_time_ns=ZakatTracker.time(), debug=debug)
3576                        if debug:
3577                            print('b-exchange', b_exchange)
3578                        assert b_exchange['rate'] == rate
3579                    case 3:  # transfer
3580                        _, x, a, b, desc, a_balance, b_balance = case
3581                        self.transfer(x, a, b, desc, debug=debug)
3582
3583                        cached_value = self.balance(a, cached=True)
3584                        fresh_value = self.balance(a, cached=False)
3585                        if debug:
3586                            print(
3587                                'account', a,
3588                                'cached_value', cached_value,
3589                                'fresh_value', fresh_value,
3590                                'a_balance', a_balance,
3591                            )
3592                        assert cached_value == a_balance
3593                        assert fresh_value == a_balance
3594
3595                        cached_value = self.balance(b, cached=True)
3596                        fresh_value = self.balance(b, cached=False)
3597                        if debug:
3598                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
3599                        assert cached_value == b_balance
3600                        assert fresh_value == b_balance
3601
3602            # Transfer all in many chunks randomly from B to A
3603            a_SAR_balance = 137125
3604            b_USD_balance = 50100
3605            b_USD_exchange = self.exchange(b_USD)
3606            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
3607            if debug:
3608                print('amounts', amounts)
3609            i = 0
3610            for x in amounts:
3611                if debug:
3612                    print(f'{i} - transfer-with-exchange({x})')
3613                self.transfer(
3614                    unscaled_amount=self.unscale(x),
3615                    from_account=b_USD,
3616                    to_account=a_SAR,
3617                    desc=f'{x} USD -> SAR',
3618                    debug=debug,
3619                )
3620
3621                b_USD_balance -= x
3622                cached_value = self.balance(b_USD, cached=True)
3623                fresh_value = self.balance(b_USD, cached=False)
3624                if debug:
3625                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3626                          b_USD_balance)
3627                assert cached_value == b_USD_balance
3628                assert fresh_value == b_USD_balance
3629
3630                a_SAR_balance += int(x * b_USD_exchange['rate'])
3631                cached_value = self.balance(a_SAR, cached=True)
3632                fresh_value = self.balance(a_SAR, cached=False)
3633                if debug:
3634                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3635                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
3636                assert cached_value == a_SAR_balance
3637                assert fresh_value == a_SAR_balance
3638                i += 1
3639
3640            # Transfer all in many chunks randomly from C to A
3641            c_SAR_balance = 37500
3642            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
3643            if debug:
3644                print('amounts', amounts)
3645            i = 0
3646            for x in amounts:
3647                if debug:
3648                    print(f'{i} - transfer-with-exchange({x})')
3649                self.transfer(
3650                    unscaled_amount=self.unscale(x),
3651                    from_account=c_SAR,
3652                    to_account=a_SAR,
3653                    desc=f'{x} SAR -> a_SAR',
3654                    debug=debug,
3655                )
3656
3657                c_SAR_balance -= x
3658                cached_value = self.balance(c_SAR, cached=True)
3659                fresh_value = self.balance(c_SAR, cached=False)
3660                if debug:
3661                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3662                          c_SAR_balance)
3663                assert cached_value == c_SAR_balance
3664                assert fresh_value == c_SAR_balance
3665
3666                a_SAR_balance += x
3667                cached_value = self.balance(a_SAR, cached=True)
3668                fresh_value = self.balance(a_SAR, cached=False)
3669                if debug:
3670                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3671                          a_SAR_balance)
3672                assert cached_value == a_SAR_balance
3673                assert fresh_value == a_SAR_balance
3674                i += 1
3675
3676            assert self.export_json('accounts-transfer-with-exchange-rates.json')
3677            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
3678
3679            # check & zakat with exchange rates for many cycles
3680
3681            lock = None
3682            for rate, values in {
3683                1: {
3684                    'in': [1000, 2000, 10000],
3685                    'exchanged': [100000, 200000, 1000000],
3686                    'out': [2500, 5000, 73140],
3687                },
3688                3.75: {
3689                    'in': [200, 1000, 5000],
3690                    'exchanged': [75000, 375000, 1875000],
3691                    'out': [1875, 9375, 137138],
3692                },
3693            }.items():
3694                a, b, c = values['in']
3695                m, n, o = values['exchanged']
3696                x, y, z = values['out']
3697                if debug:
3698                    print('rate', rate, 'values', values)
3699                for case in [
3700                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3701                        {'safe': {0: {'below_nisab': x}}},
3702                    ], False, m),
3703                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3704                        {'safe': {0: {'count': 1, 'total': y}}},
3705                    ], True, n),
3706                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
3707                        {'cave': {0: {'count': 3, 'total': z}}},
3708                    ], True, o),
3709                ]:
3710                    if debug:
3711                        print(f'############# check(rate: {rate}) #############')
3712                        print('case', case)
3713                    self.reset()
3714                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
3715                    self.track(
3716                        unscaled_value=case[0],
3717                        desc='test-check',
3718                        account=case[1],
3719                        logging=True,
3720                        created_time_ns=case[2],
3721                    )
3722                    assert self.snapshot()
3723
3724                    # assert self.nolock()
3725                    # history_size = len(self._vault['history'])
3726                    # print('history_size', history_size)
3727                    # assert history_size == 2
3728                    lock = self.lock()
3729                    assert lock
3730                    assert not self.nolock()
3731                    report = self.check(2.17, None, debug)
3732                    (valid, brief, plan) = report
3733                    if debug:
3734                        print('brief', brief)
3735                    assert valid == case[4]
3736                    assert case[5] == brief[0]
3737                    assert case[5] == brief[1]
3738
3739                    if debug:
3740                        pp().pprint(plan)
3741
3742                    for x in plan:
3743                        assert case[1] == x
3744                        if 'total' in case[3][0][x][0].keys():
3745                            assert case[3][0][x][0]['total'] == int(brief[2])
3746                            assert int(plan[x][0]['total']) == case[3][0][x][0]['total']
3747                            assert int(plan[x][0]['count']) == case[3][0][x][0]['count']
3748                        else:
3749                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3750                    if debug:
3751                        pp().pprint(report)
3752                    result = self.zakat(report, debug=debug)
3753                    if debug:
3754                        print('zakat-result', result, case[4])
3755                    assert result == case[4]
3756                    report = self.check(2.17, None, debug)
3757                    (valid, brief, plan) = report
3758                    assert valid is False
3759
3760            history_size = len(self._vault['history'])
3761            if debug:
3762                print('history_size', history_size)
3763            assert history_size == 3
3764            assert not self.nolock()
3765            assert self.recall(False, debug=debug) is False
3766            self.free(lock)
3767            assert self.nolock()
3768
3769            for i in range(3, 0, -1):
3770                history_size = len(self._vault['history'])
3771                if debug:
3772                    print('history_size', history_size)
3773                assert history_size == i
3774                assert self.recall(False, debug=debug) is True
3775
3776            assert self.nolock()
3777            assert self.recall(False, debug=debug) is False
3778
3779            history_size = len(self._vault['history'])
3780            if debug:
3781                print('history_size', history_size)
3782            assert history_size == 0
3783
3784            account_size = len(self._vault['account'])
3785            if debug:
3786                print('account_size', account_size)
3787            assert account_size == 0
3788
3789            report_size = len(self._vault['report'])
3790            if debug:
3791                print('report_size', report_size)
3792            assert report_size == 0
3793
3794            assert self.nolock()
3795
3796            # csv
3797
3798            csv_count = 1000
3799
3800            for with_rate, path in {
3801                False: 'test-import_csv-no-exchange',
3802                True: 'test-import_csv-with-exchange',
3803            }.items():
3804
3805                if debug:
3806                    print('test_import_csv', with_rate, path)
3807
3808                csv_path = path + '.csv'
3809                if os.path.exists(csv_path):
3810                    os.remove(csv_path)
3811                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
3812                if debug:
3813                    print('generate_random_csv_file', c)
3814                assert c == csv_count
3815                assert os.path.getsize(csv_path) > 0
3816                cache_path = self.import_csv_cache_path()
3817                if os.path.exists(cache_path):
3818                    os.remove(cache_path)
3819                self.reset()
3820                lock = self.lock()
3821                (created, found, bad) = self.import_csv(csv_path, debug)
3822                bad_count = len(bad)
3823                assert bad_count > 0
3824                if debug:
3825                    print(f'csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})')
3826                    print('bad', bad)
3827                tmp_size = os.path.getsize(cache_path)
3828                assert tmp_size > 0
3829
3830                # TODO: assert created + found + bad_count == csv_count
3831                # TODO: assert created == csv_count
3832                # TODO: assert bad_count == 0
3833                (created_2, found_2, bad_2) = self.import_csv(csv_path)
3834                bad_2_count = len(bad_2)
3835                if debug:
3836                    print(f'csv-imported: ({created_2}, {found_2}, {bad_2_count})')
3837                    print('bad', bad)
3838                assert bad_2_count > 0
3839                # TODO: assert tmp_size == os.path.getsize(cache_path)
3840                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
3841                # TODO: assert created == found_2
3842                # TODO: assert bad_count == bad_2_count
3843                # TODO: assert found_2 == csv_count
3844                # TODO: assert bad_2_count == 0
3845                # TODO: assert created_2 == 0
3846
3847                # payment parts
3848
3849                positive_parts = self.build_payment_parts(100, positive_only=True)
3850                assert self.check_payment_parts(positive_parts) != 0
3851                assert self.check_payment_parts(positive_parts) != 0
3852                all_parts = self.build_payment_parts(300, positive_only=False)
3853                assert self.check_payment_parts(all_parts) != 0
3854                assert self.check_payment_parts(all_parts) != 0
3855                if debug:
3856                    pp().pprint(positive_parts)
3857                    pp().pprint(all_parts)
3858                # dynamic discount
3859                suite = []
3860                count = 3
3861                for exceed in [False, True]:
3862                    case = []
3863                    for parts in [positive_parts, all_parts]:
3864                        part = parts.copy()
3865                        demand = part['demand']
3866                        if debug:
3867                            print(demand, part['total'])
3868                        i = 0
3869                        z = demand / count
3870                        cp = {
3871                            'account': {},
3872                            'demand': demand,
3873                            'exceed': exceed,
3874                            'total': part['total'],
3875                        }
3876                        j = ''
3877                        for x, y in part['account'].items():
3878                            x_exchange = self.exchange(x)
3879                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
3880                            if exceed and zz <= demand:
3881                                i += 1
3882                                y['part'] = zz
3883                                if debug:
3884                                    print(exceed, y)
3885                                cp['account'][x] = y
3886                                case.append(y)
3887                            elif not exceed and y['balance'] >= zz:
3888                                i += 1
3889                                y['part'] = zz
3890                                if debug:
3891                                    print(exceed, y)
3892                                cp['account'][x] = y
3893                                case.append(y)
3894                            j = x
3895                            if i >= count:
3896                                break
3897                        if len(cp['account'][j]) > 0:
3898                            suite.append(cp)
3899                if debug:
3900                    print('suite', len(suite))
3901                for case in suite:
3902                    if debug:
3903                        print('case', case)
3904                    result = self.check_payment_parts(case)
3905                    if debug:
3906                        print('check_payment_parts', result, f'exceed: {exceed}')
3907                    assert result == 0
3908
3909                    report = self.check(2.17, None, debug)
3910                    (valid, brief, plan) = report
3911                    if debug:
3912                        print('valid', valid)
3913                    zakat_result = self.zakat(report, parts=case, debug=debug)
3914                    if debug:
3915                        print('zakat-result', zakat_result)
3916                    assert valid == zakat_result
3917
3918                assert self.free(lock)
3919
3920            assert self.save(path + f'.{self.ext()}')
3921            assert self.export_json(path + '.json')
3922
3923            assert self.export_json('1000-transactions-test.json')
3924            assert self.save(f'1000-transactions-test.{self.ext()}')
3925            return True
3926        except Exception as e:
3927            # pp().pprint(self._vault)
3928            assert self.export_json('test-snapshot.json')
3929            assert self.save(f'test-snapshot.{self.ext()}')
3930            raise e
def test(path: str = None, debug: bool = False):
3933def test(path: str = None, debug: bool = False):
3934    """
3935    Executes a test suite for the ZakatTracker.
3936
3937    This function initializes a ZakatTracker instance, optionally using a specified
3938    database path or a temporary directory. It then runs the test suite and, if debug
3939    mode is enabled, prints detailed test results and execution time.
3940
3941    Parameters:
3942    - path (str, optional): The path to the ZakatTracker database. If None, a
3943                            temporary directory is created. Defaults to None.
3944    - debug (bool, optional): Enables debug mode, which prints detailed test
3945                            results and execution time. Defaults to False.
3946
3947    Returns:
3948    None. The function asserts the result of the ZakatTracker's test suite.
3949
3950    Raises:
3951    - AssertionError: If the ZakatTracker's test suite fails.
3952
3953    Example:
3954        test()  # Runs tests using a temporary database.
3955        test(debug=True)  # Runs the test suite in debug mode with a temporary directory.
3956        test(path="/path/to/my/db")  # Runs tests using a specified database path.
3957        test(path="/path/to/my/db", debug=False) # runs test suite with specified path.
3958    """
3959    no_path = path is None
3960    if no_path:
3961        path = tempfile.mkdtemp()
3962    if os.path.exists(path):
3963        shutil.rmtree(path)
3964    assert ZakatTracker(':memory:')._memory_mode
3965    ledger = ZakatTracker(
3966        db_path=path,
3967        history_mode=True,
3968    )
3969    start = time.time_ns()
3970    assert not ledger._memory_mode
3971    assert ledger.test(debug=debug)
3972    if no_path and os.path.exists(path):
3973        shutil.rmtree(path)
3974    if debug:
3975        print('#########################')
3976        print('######## TEST DONE ########')
3977        print('#########################')
3978        print(ZakatTracker.duration_from_nanoseconds(time.time_ns() - start))
3979        print('#########################')

Executes a test suite for the ZakatTracker.

This function initializes a ZakatTracker instance, optionally using a specified database path or a temporary directory. It then runs the test suite and, if debug mode is enabled, prints detailed test results and execution time.

Parameters:

  • path (str, optional): The path to the ZakatTracker database. If None, a temporary directory is created. Defaults to None.
  • debug (bool, optional): Enables debug mode, which prints detailed test results and execution time. Defaults to False.

Returns: None. The function asserts the result of the ZakatTracker's test suite.

Raises:

  • AssertionError: If the ZakatTracker's test suite fails.

Example: test() # Runs tests using a temporary database. test(debug=True) # Runs the test suite in debug mode with a temporary directory. test(path="/path/to/my/db") # Runs tests using a specified database path. test(path="/path/to/my/db", debug=False) # runs test suite with specified path.

class Action(enum.Enum):
100class Action(enum.Enum):
101    """
102    Enumeration representing various actions that can be performed.
103
104    Members:
105        - CREATE: Represents the creation action (0).
106        - TRACK: Represents the tracking action (1).
107        - LOG: Represents the logging action (2).
108        - SUB: Represents the subtract action (3).
109        - ADD_FILE: Represents the action of adding a file (4).
110        - REMOVE_FILE: Represents the action of removing a file (5).
111        - BOX_TRANSFER: Represents the action of transferring a box (6).
112        - EXCHANGE: Represents the exchange action (7).
113        - REPORT: Represents the reporting action (8).
114        - ZAKAT: Represents a Zakat related action (9).
115    """
116    CREATE = 0
117    TRACK = 1
118    LOG = 2
119    SUB = 3
120    ADD_FILE = 4
121    REMOVE_FILE = 5
122    BOX_TRANSFER = 6
123    EXCHANGE = 7
124    REPORT = 8
125    ZAKAT = 9

Enumeration representing various actions that can be performed.

Members: - CREATE: Represents the creation action (0). - TRACK: Represents the tracking action (1). - LOG: Represents the logging action (2). - SUB: Represents the subtract action (3). - ADD_FILE: Represents the action of adding a file (4). - REMOVE_FILE: Represents the action of removing a file (5). - BOX_TRANSFER: Represents the action of transferring a box (6). - EXCHANGE: Represents the exchange action (7). - REPORT: Represents the reporting action (8). - ZAKAT: Represents a Zakat related action (9).

CREATE = <Action.CREATE: 0>
TRACK = <Action.TRACK: 1>
LOG = <Action.LOG: 2>
SUB = <Action.SUB: 3>
ADD_FILE = <Action.ADD_FILE: 4>
REMOVE_FILE = <Action.REMOVE_FILE: 5>
BOX_TRANSFER = <Action.BOX_TRANSFER: 6>
EXCHANGE = <Action.EXCHANGE: 7>
REPORT = <Action.REPORT: 8>
ZAKAT = <Action.ZAKAT: 9>
class JSONEncoder(json.encoder.JSONEncoder):
142class JSONEncoder(json.JSONEncoder):
143    """
144    Custom JSON encoder to handle specific object types.
145
146    This encoder overrides the default `default` method to serialize:
147    - `Action` and `MathOperation` enums as their member names.
148    - `decimal.Decimal` instances as floats.
149
150    Example:
151        >>> json.dumps(Action.CREATE, cls=JSONEncoder)
152        ''CREATE''
153        >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
154        '10.5'
155    """
156    def default(self, o):
157        """
158        Overrides the default `default` method to serialize specific object types.
159
160        Parameters:
161        - o: The object to serialize.
162
163        Returns:
164        - The serialized object.
165        """
166        if isinstance(o, (Action, MathOperation)):
167            return o.name  # Serialize as the enum member's name
168        if isinstance(o, decimal.Decimal):
169            return float(o)
170        if isinstance(o, Exception):
171            return str(o)
172        return super().default(o)

Custom JSON encoder to handle specific object types.

This encoder overrides the default default method to serialize:

Example:

json.dumps(Action.CREATE, cls=JSONEncoder) ''CREATE'' json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder) '10.5'

def default(self, o):
156    def default(self, o):
157        """
158        Overrides the default `default` method to serialize specific object types.
159
160        Parameters:
161        - o: The object to serialize.
162
163        Returns:
164        - The serialized object.
165        """
166        if isinstance(o, (Action, MathOperation)):
167            return o.name  # Serialize as the enum member's name
168        if isinstance(o, decimal.Decimal):
169            return float(o)
170        if isinstance(o, Exception):
171            return str(o)
172        return super().default(o)

Overrides the default default method to serialize specific object types.

Parameters:

  • o: The object to serialize.

Returns:

  • The serialized object.
class JSONDecoder(json.decoder.JSONDecoder):
174class JSONDecoder(json.JSONDecoder):
175    """
176    Custom JSON decoder to handle specific object types.
177
178    This decoder overrides the `object_hook` method to deserialize:
179    - Strings representing enum member names back to their respective enum values.
180    - Floats back to `decimal.Decimal` instances.
181
182    Example:
183        >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
184        {'action': <Action.CREATE: 1>}
185        >>> json.loads('{"value": 10.5}', cls=JSONDecoder)
186        {'value': Decimal('10.5')}
187    """
188    def object_hook(self, obj):
189        """
190        Overrides the default `object_hook` method to deserialize specific object types.
191
192        Parameters:
193        - obj: The object to deserialize.
194
195        Returns:
196        - The deserialized object.
197        """
198        if isinstance(obj, str) and obj in Action.__members__:
199            return Action[obj]
200        if isinstance(obj, str) and obj in MathOperation.__members__:
201            return MathOperation[obj]
202        if isinstance(obj, float):
203            return decimal.Decimal(str(obj))
204        return obj

Custom JSON decoder to handle specific object types.

This decoder overrides the object_hook method to deserialize:

  • Strings representing enum member names back to their respective enum values.
  • Floats back to decimal.Decimal instances.

Example:

json.loads('{"action": "CREATE"}', cls=JSONDecoder) {'action': <Action.CREATE: 1>} json.loads('{"value": 10.5}', cls=JSONDecoder) {'value': Decimal('10.5')}

def object_hook(self, obj):
188    def object_hook(self, obj):
189        """
190        Overrides the default `object_hook` method to deserialize specific object types.
191
192        Parameters:
193        - obj: The object to deserialize.
194
195        Returns:
196        - The deserialized object.
197        """
198        if isinstance(obj, str) and obj in Action.__members__:
199            return Action[obj]
200        if isinstance(obj, str) and obj in MathOperation.__members__:
201            return MathOperation[obj]
202        if isinstance(obj, float):
203            return decimal.Decimal(str(obj))
204        return obj

Overrides the default object_hook method to deserialize specific object types.

Parameters:

  • obj: The object to deserialize.

Returns:

  • The deserialized object.
class MathOperation(enum.Enum):
128class MathOperation(enum.Enum):
129    """
130    Enumeration representing mathematical operations.
131
132    Members:
133        - ADDITION: Represents the addition operation (0).
134        - EQUAL: Represents the equality operation (1).
135        - SUBTRACTION: Represents the subtraction operation (2).
136    """
137    ADDITION = 0
138    EQUAL = 1
139    SUBTRACTION = 2

Enumeration representing mathematical operations.

Members: - ADDITION: Represents the addition operation (0). - EQUAL: Represents the equality operation (1). - SUBTRACTION: Represents the subtraction operation (2).

ADDITION = <MathOperation.ADDITION: 0>
EQUAL = <MathOperation.EQUAL: 1>
SUBTRACTION = <MathOperation.SUBTRACTION: 2>
class WeekDay(enum.Enum):
78class WeekDay(enum.Enum):
79    """
80    Enumeration representing the days of the week.
81
82    Members:
83        - MONDAY: Represents Monday (0).
84        - TUESDAY: Represents Tuesday (1).
85        - WEDNESDAY: Represents Wednesday (2).
86        - THURSDAY: Represents Thursday (3).
87        - FRIDAY: Represents Friday (4).
88        - SATURDAY: Represents Saturday (5).
89        - SUNDAY: Represents Sunday (6).
90    """
91    MONDAY = 0
92    TUESDAY = 1
93    WEDNESDAY = 2
94    THURSDAY = 3
95    FRIDAY = 4
96    SATURDAY = 5
97    SUNDAY = 6

Enumeration representing the days of the week.

Members: - MONDAY: Represents Monday (0). - TUESDAY: Represents Tuesday (1). - WEDNESDAY: Represents Wednesday (2). - THURSDAY: Represents Thursday (3). - FRIDAY: Represents Friday (4). - SATURDAY: Represents Saturday (5). - SUNDAY: Represents Sunday (6).

MONDAY = <WeekDay.MONDAY: 0>
TUESDAY = <WeekDay.TUESDAY: 1>
WEDNESDAY = <WeekDay.WEDNESDAY: 2>
THURSDAY = <WeekDay.THURSDAY: 3>
FRIDAY = <WeekDay.FRIDAY: 4>
SATURDAY = <WeekDay.SATURDAY: 5>
SUNDAY = <WeekDay.SUNDAY: 6>
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 WSGI 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 camel 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    # Upload directory
100    upload_directory = "./uploads"
101    os.makedirs(upload_directory, exist_ok=True)
102
103    # HTML templates
104    upload_form = f"""
105    <html lang="en">
106        <head>
107            <title>Zakat File Server</title>
108        </head>
109    <body>
110    <h1>Zakat File Server</h1>
111    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
112    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
113    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
114        <input type="file" name="file" required><br/>
115        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
116        <label for="database">Database File</label><br/>
117        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
118        <label for="csv">CSV File</label><br/>
119        <input type="submit" value="Upload"><br/>
120    </form>
121    </body></html>
122    """
123
124    # WSGI application
125    def wsgi_app(environ, start_response):
126        path = environ.get('PATH_INFO', '')
127        method = environ.get('REQUEST_METHOD', 'GET')
128
129        if path == f"/{file_uuid}/get" and method == 'GET':
130            # GET: Serve the existing file
131            try:
132                with open(database_path, "rb") as f:
133                    file_content = f.read()
134                    
135                start_response('200 OK', [
136                    ('Content-type', 'application/octet-stream'),
137                    ('Content-Disposition', f'attachment; filename="{file_name}"'),
138                    ('Content-Length', str(len(file_content)))
139                ])
140                return [file_content]
141            except FileNotFoundError:
142                start_response('404 Not Found', [('Content-type', 'text/plain')])
143                return [b'File not found']
144                
145        elif path == f"/{file_uuid}/upload" and method == 'GET':
146            # GET: Serve the upload form
147            start_response('200 OK', [('Content-type', 'text/html')])
148            return [upload_form.encode()]
149            
150        elif path == f"/{file_uuid}/upload" and method == 'POST':
151            # POST: Handle file uploads
152            try:
153                # Get content length
154                content_length = int(environ.get('CONTENT_LENGTH', 0))
155                
156                # Get content type and boundary
157                content_type = environ.get('CONTENT_TYPE', '')
158                
159                # Read the request body
160                request_body = environ['wsgi.input'].read(content_length)
161                
162                # Create a file-like object from the request body
163                # request_body_file = io.BytesIO(request_body)
164                
165                # Parse the multipart form data using WSGI approach
166                # First, detect the boundary from content_type
167                boundary = None
168                for part in content_type.split(';'):
169                    part = part.strip()
170                    if part.startswith('boundary='):
171                        boundary = part[9:]
172                        if boundary.startswith('"') and boundary.endswith('"'):
173                            boundary = boundary[1:-1]
174                        break
175                
176                if not boundary:
177                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
178                    return [b"Missing boundary in multipart form data"]
179                
180                # Process multipart data
181                parts = request_body.split(f'--{boundary}'.encode())
182                
183                # Initialize variables to store form data
184                upload_type = None
185                # file_item = None
186                file_data = None
187                filename = None
188                
189                # Process each part
190                for part in parts:
191                    if not part.strip():
192                        continue
193                    
194                    # Split header and body
195                    try:
196                        headers_raw, body = part.split(b'\r\n\r\n', 1)
197                        headers_text = headers_raw.decode('utf-8')
198                    except ValueError:
199                        continue
200                    
201                    # Parse headers
202                    headers = {}
203                    for header_line in headers_text.split('\r\n'):
204                        if ':' in header_line:
205                            name, value = header_line.split(':', 1)
206                            headers[name.strip().lower()] = value.strip()
207                    
208                    # Get content disposition
209                    content_disposition = headers.get('content-disposition', '')
210                    if not content_disposition.startswith('form-data'):
211                        continue
212                    
213                    # Extract field name
214                    field_name = None
215                    for item in content_disposition.split(';'):
216                        item = item.strip()
217                        if item.startswith('name='):
218                            field_name = item[5:].strip('"\'')
219                            break
220                    
221                    if not field_name:
222                        continue
223                    
224                    # Handle upload_type field
225                    if field_name == 'upload_type':
226                        # Remove trailing data including the boundary
227                        body_end = body.find(b'\r\n--')
228                        if body_end >= 0:
229                            body = body[:body_end]
230                        upload_type = body.decode('utf-8').strip()
231                    
232                    # Handle file field
233                    elif field_name == 'file':
234                        # Extract filename
235                        for item in content_disposition.split(';'):
236                            item = item.strip()
237                            if item.startswith('filename='):
238                                filename = item[9:].strip('"\'')
239                                break
240                        
241                        if filename:
242                            # Remove trailing data including the boundary
243                            body_end = body.find(b'\r\n--')
244                            if body_end >= 0:
245                                body = body[:body_end]
246                            file_data = body
247                
248                if debug:
249                    print('upload_type', upload_type)
250                    
251                if debug:
252                    print('upload_type:', upload_type)
253                    print('filename:', filename)
254                
255                if not upload_type or upload_type not in [FileType.Database.value, FileType.CSV.value]:
256                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
257                    return [b"Invalid upload type"]
258                
259                if not filename or not file_data:
260                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
261                    return [b"Missing file data"]
262                
263                if debug:
264                    print(f'Uploaded filename: {filename}')
265                
266                # Save the file
267                file_path = os.path.join(upload_directory, upload_type)
268                with open(file_path, 'wb') as f:
269                    f.write(file_data)
270                
271                # Process based on file type
272                if upload_type == FileType.Database.value:
273                    try:
274                        # Verify database file
275                        if database_callback is not None:
276                            database_callback(file_path)
277                        
278                        # Copy database into the original path
279                        shutil.copy2(file_path, database_path)
280                        
281                        start_response('200 OK', [('Content-type', 'text/plain')])
282                        return [b"Database file uploaded successfully."]
283                    except Exception as e:
284                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
285                        return [str(e).encode()]
286                
287                elif upload_type == FileType.CSV.value:
288                    try:
289                        if csv_callback is not None:
290                            result = csv_callback(file_path, database_path, debug)
291                            if debug:
292                                print(f'CSV imported: {result}')
293                            if len(result[2]) != 0:
294                                start_response('200 OK', [('Content-type', 'application/json')])
295                                return [json.dumps(result).encode()]
296                        
297                        start_response('200 OK', [('Content-type', 'text/plain')])
298                        return [b"CSV file uploaded successfully."]
299                    except Exception as e:
300                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
301                        return [str(e).encode()]
302            
303            except Exception as e:
304                start_response('500 Internal Server Error', [('Content-type', 'text/plain')])
305                return [f"Error processing upload: {str(e)}".encode()]
306        
307        else:
308            # 404 for anything else
309            start_response('404 Not Found', [('Content-type', 'text/plain')])
310            return [b'Not Found']
311    
312    # Create and start the server
313    httpd = make_server('localhost', port, wsgi_app)
314    server_thread = threading.Thread(target=httpd.serve_forever)
315    
316    def shutdown_server():
317        nonlocal httpd, server_thread
318        httpd.shutdown()
319        server_thread.join()  # Wait for the thread to finish
320    
321    return file_name, download_url, upload_url, server_thread, shutdown_server

Starts a multi-purpose WSGI 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 camel 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'>